Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/hooks/use-inline-rename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ interface UseInlineRenameProps {
* `mutateAsync(...)`) — NOT a fire-and-forget `mutate(...)` — so `isSaving`
* spans the in-flight request and a rejection can revive the edit session.
*/
onSave: (id: string, newName: string) => void | Promise<unknown>
onSave: (id: string, newName: string) => unknown
}

/**
Expand Down
44 changes: 43 additions & 1 deletion apps/sim/lib/auth/access-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
},
}))

import { getAccessControlConfig } from '@/lib/auth/access-control'
import { getAccessControlConfig, isEmailInDenylist } from '@/lib/auth/access-control'

const empty: AccessControlConfig = {
blockedSignupDomains: [],
Expand Down Expand Up @@ -104,3 +104,45 @@ describe('getAccessControlConfig', () => {
})
})
})

describe('isEmailInDenylist', () => {
it('returns false when denylist is null, empty, or email is missing', () => {
expect(isEmailInDenylist('a@example.com', null)).toBe(false)
expect(isEmailInDenylist('a@example.com', [])).toBe(false)
expect(isEmailInDenylist(null, ['example.com'])).toBe(false)
expect(isEmailInDenylist(undefined, ['example.com'])).toBe(false)
expect(isEmailInDenylist('', ['example.com'])).toBe(false)
})

it('returns false when email has no @', () => {
expect(isEmailInDenylist('not-an-email', ['example.com'])).toBe(false)
})

it('matches exact domain', () => {
expect(isEmailInDenylist('user@dpdns.org', ['dpdns.org'])).toBe(true)
expect(isEmailInDenylist('user@DPDNS.ORG', ['dpdns.org'])).toBe(true)
})

it('matches arbitrary-depth subdomains of a listed parent zone', () => {
expect(isEmailInDenylist('user@xx.lucky04.dpdns.org', ['dpdns.org'])).toBe(true)
expect(isEmailInDenylist('user@a.b.c.qzz.io', ['qzz.io'])).toBe(true)
})

it('does not match look-alike domains', () => {
expect(isEmailInDenylist('user@xdpdns.org', ['dpdns.org'])).toBe(false)
expect(isEmailInDenylist('user@notdpdns.org', ['dpdns.org'])).toBe(false)
})

it('does not match disallowed domains', () => {
expect(isEmailInDenylist('user@gmail.com', ['dpdns.org', 'qzz.io'])).toBe(false)
expect(isEmailInDenylist('user@example.com', ['dpdns.org'])).toBe(false)
})

it('handles multiple denylist entries', () => {
const denylist = ['dpdns.org', 'qzz.io', 'cc.cd']
expect(isEmailInDenylist('user@foo.dpdns.org', denylist)).toBe(true)
expect(isEmailInDenylist('user@bar.qzz.io', denylist)).toBe(true)
expect(isEmailInDenylist('user@baz.cc.cd', denylist)).toBe(true)
expect(isEmailInDenylist('user@example.com', denylist)).toBe(false)
})
})
14 changes: 14 additions & 0 deletions apps/sim/lib/auth/access-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ export interface AccessControlConfig {
blockedEmailMxHosts: string[]
}

/**
* True when the email's domain matches a denylist entry exactly or is a
* subdomain of one.
*/
export function isEmailInDenylist(
email: string | undefined | null,
denylist: readonly string[] | null
): boolean {
if (!denylist || denylist.length === 0 || !email) return false
const domain = email.split('@')[1]?.toLowerCase()
if (!domain) return false
return denylist.some((entry) => domain === entry || domain.endsWith(`.${entry}`))
}

function normalizeList(values: unknown): string[] {
if (!Array.isArray(values)) return []
return Array.from(new Set(values.map((v) => String(v).trim().toLowerCase()).filter(Boolean)))
Expand Down
47 changes: 0 additions & 47 deletions apps/sim/lib/auth/auth.test.ts

This file was deleted.

40 changes: 27 additions & 13 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
renderPasswordResetEmail,
renderWelcomeEmail,
} from '@/components/emails'
import { getAccessControlConfig } from '@/lib/auth/access-control'
import { getAccessControlConfig, isEmailInDenylist } from '@/lib/auth/access-control'
import { sendPlanWelcomeEmail } from '@/lib/billing'
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
import {
Expand Down Expand Up @@ -138,16 +138,6 @@ function getMicrosoftUserInfoFromIdToken(tokens: { accessToken?: string }, provi
}
}

export function isEmailInDenylist(
email: string | undefined | null,
denylist: readonly string[] | null
): boolean {
if (!denylist || denylist.length === 0 || !email) return false
const domain = email.split('@')[1]?.toLowerCase()
if (!domain) return false
return denylist.some((entry) => domain === entry || domain.endsWith(`.${entry}`))
}

const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) =>
logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value })
)
Expand Down Expand Up @@ -592,6 +582,26 @@ export const auth = betterAuth({
session: {
create: {
before: async (session) => {
// Blocked-domain accounts must not establish sessions, regardless of
// provider (email/password, OAuth, SSO). Deliberately outside the
// try below — a thrown APIError must propagate, not be swallowed.
const accessControl = await getAccessControlConfig()
if (accessControl.blockedSignupDomains.length > 0) {
const [sessionUser] = await db
.select({ email: schema.user.email })
.from(schema.user)
.where(eq(schema.user.id, session.userId))
.limit(1)
if (isEmailInDenylist(sessionUser?.email, accessControl.blockedSignupDomains)) {
logger.warn('Blocking session creation for blocked-domain account', {
userId: session.userId,
})
throw new APIError('FORBIDDEN', {
message: 'Access restricted. Please contact your administrator.',
})
}
}

try {
// Find the first organization this user is a member of
const members = await db
Expand Down Expand Up @@ -826,9 +836,13 @@ export const auth = betterAuth({
}
}

if (isSignUp && isEmailInDenylist(ctx.body?.email, accessControl.blockedSignupDomains)) {
// Blocked domains gate both signup and sign-in. OAuth/SSO sign-ins have
// no email in the body here; the session.create.before hook covers them.
if (isEmailInDenylist(requestEmail, accessControl.blockedSignupDomains)) {
throw new APIError('FORBIDDEN', {
message: 'Sign-ups from this email domain are not allowed.',
message: isSignUp
? 'Sign-ups from this email domain are not allowed.'
: 'Access restricted. Please contact your administrator.',
})
}

Expand Down
112 changes: 112 additions & 0 deletions apps/sim/lib/auth/ban.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockWhere, envRef } = vi.hoisted(() => ({
mockWhere: vi.fn(),
envRef: { BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined },
}))

vi.mock('@sim/db', () => ({
db: { select: vi.fn(() => ({ from: vi.fn(() => ({ where: mockWhere })) })) },
user: { id: 'id', email: 'email', banned: 'banned', banExpires: 'banExpires' },
}))
vi.mock('drizzle-orm', () => ({ inArray: vi.fn(), sql: vi.fn() }))
vi.mock('@/lib/core/config/appconfig', () => ({ fetchAppConfigProfile: vi.fn() }))
vi.mock('@/lib/core/config/env', () => ({
get env() {
return envRef
},
}))
vi.mock('@/lib/core/config/feature-flags', () => ({ isAppConfigEnabled: false }))

import { getActivelyBannedUserIds, isBanActive, isEmailBlocked } from '@/lib/auth/ban'

describe('isBanActive', () => {
it('returns true for a permanent ban', () => {
expect(isBanActive({ banned: true, banExpires: null })).toBe(true)
})

it('returns false for an expired temporary ban', () => {
expect(isBanActive({ banned: true, banExpires: new Date(Date.now() - 1000) })).toBe(false)
})

it('returns true for an unexpired temporary ban', () => {
expect(isBanActive({ banned: true, banExpires: new Date(Date.now() + 60_000) })).toBe(true)
})

it('returns false when not banned', () => {
expect(isBanActive({ banned: false, banExpires: null })).toBe(false)
expect(isBanActive({ banned: null, banExpires: null })).toBe(false)
})
})

describe('isEmailBlocked', () => {
beforeEach(() => {
vi.clearAllMocks()
envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com'
mockWhere.mockResolvedValue([])
})

it('returns true for blocked domains and subdomains without querying users', async () => {
expect(await isEmailBlocked('a@bad.com')).toBe(true)
expect(await isEmailBlocked('a@mail.bad.com')).toBe(true)
expect(mockWhere).not.toHaveBeenCalled()
})

it('returns true when the email belongs to an actively banned account', async () => {
mockWhere.mockResolvedValue([{ banned: true, banExpires: null }])
expect(await isEmailBlocked('a@good.com')).toBe(true)
})

it('returns false for clean accounts and missing emails', async () => {
expect(await isEmailBlocked('a@good.com')).toBe(false)
expect(await isEmailBlocked(null)).toBe(false)
expect(await isEmailBlocked(undefined)).toBe(false)
})
})

describe('getActivelyBannedUserIds', () => {
beforeEach(() => {
vi.clearAllMocks()
envRef.BLOCKED_SIGNUP_DOMAINS = undefined
mockWhere.mockResolvedValue([])
})

it('short-circuits on empty input without querying', async () => {
expect(await getActivelyBannedUserIds([])).toEqual([])
expect(await getActivelyBannedUserIds([''])).toEqual([])
expect(mockWhere).not.toHaveBeenCalled()
})

it('returns ids with an active db ban', async () => {
mockWhere.mockResolvedValue([
{ id: 'u1', email: 'a@ok.com', banned: true, banExpires: null },
{ id: 'u2', email: 'b@ok.com', banned: false, banExpires: null },
])
expect(await getActivelyBannedUserIds(['u1', 'u2'])).toEqual(['u1'])
})

it('treats an expired ban as lifted', async () => {
mockWhere.mockResolvedValue([
{ id: 'u1', email: 'a@ok.com', banned: true, banExpires: new Date(Date.now() - 1000) },
])
expect(await getActivelyBannedUserIds(['u1'])).toEqual([])
})

it('returns ids whose email domain is in the blocked-domains list, including subdomains', async () => {
envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com'
mockWhere.mockResolvedValue([
{ id: 'u1', email: 'a@bad.com', banned: false, banExpires: null },
{ id: 'u2', email: 'b@mail.bad.com', banned: false, banExpires: null },
{ id: 'u3', email: 'c@good.com', banned: false, banExpires: null },
])
expect(await getActivelyBannedUserIds(['u1', 'u2', 'u3'])).toEqual(['u1', 'u2'])
})

it('propagates db failures so callers fail closed', async () => {
mockWhere.mockRejectedValue(new Error('db down'))
await expect(getActivelyBannedUserIds(['u1'])).rejects.toThrow('db down')
})
})
54 changes: 54 additions & 0 deletions apps/sim/lib/auth/ban.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { db, user } from '@sim/db'
import { inArray, sql } from 'drizzle-orm'
import { getAccessControlConfig, isEmailInDenylist } from '@/lib/auth/access-control'

/**
* True when a ban is currently in effect. Mirrors better-auth admin-plugin
* semantics: a ban whose `banExpires` is in the past is treated as lifted.
*/
export function isBanActive(row: { banned: boolean | null; banExpires: Date | null }): boolean {
if (!row.banned) return false
if (row.banExpires && row.banExpires.getTime() <= Date.now()) return false
return true
}

/**
* True when a raw email (e.g. an inbound sender) is blocked: its domain is in
* the appconfig blocked-domains list, or it belongs to an account with an
* active ban. Covers senders that don't resolve to a known user id.
*/
export async function isEmailBlocked(email: string | null | undefined): Promise<boolean> {
if (!email) return false
const accessControl = await getAccessControlConfig()
if (isEmailInDenylist(email, accessControl.blockedSignupDomains)) return true
const rows = await db
.select({ banned: user.banned, banExpires: user.banExpires })
.from(user)
.where(sql`lower(${user.email}) = ${email.toLowerCase()}`)
return rows.some(isBanActive)
}

/**
* Returns the subset of the given user ids that are currently blocked: an
* active account ban, or an email domain in the appconfig blocked-domains
* list. One user query plus the cached access-control fetch. Throws on db
* failure — callers must fail closed.
*/
export async function getActivelyBannedUserIds(userIds: string[]): Promise<string[]> {
const ids = [...new Set(userIds.filter(Boolean))]
if (ids.length === 0) return []

const [accessControl, rows] = await Promise.all([
getAccessControlConfig(),
db
.select({ id: user.id, email: user.email, banned: user.banned, banExpires: user.banExpires })
.from(user)
.where(inArray(user.id, ids)),
])

return rows
.filter(
(row) => isBanActive(row) || isEmailInDenylist(row.email, accessControl.blockedSignupDomains)
)
.map((row) => row.id)
}
Loading
Loading