Skip to content
79 changes: 78 additions & 1 deletion apps/sim/lib/auth/access-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { mockFetch, envRef, flagRef } = vi.hoisted(() => ({
APPCONFIG_APPLICATION: 'sim-staging' as string | undefined,
APPCONFIG_ENVIRONMENT: 'staging' as string | undefined,
BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined,
BLOCKED_EMAILS: undefined as string | undefined,
ALLOWED_LOGIN_EMAILS: undefined as string | undefined,
ALLOWED_LOGIN_DOMAINS: undefined as string | undefined,
BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined,
Expand All @@ -33,10 +34,15 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
},
}))

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

const empty: AccessControlConfig = {
blockedSignupDomains: [],
blockedEmails: [],
allowedLoginEmails: [],
allowedLoginDomains: [],
blockedEmailMxHosts: [],
Expand All @@ -48,6 +54,7 @@ describe('getAccessControlConfig', () => {
flagRef.isAppConfigEnabled = false
Object.assign(envRef, {
BLOCKED_SIGNUP_DOMAINS: undefined,
BLOCKED_EMAILS: undefined,
ALLOWED_LOGIN_EMAILS: undefined,
ALLOWED_LOGIN_DOMAINS: undefined,
BLOCKED_EMAIL_MX_HOSTS: undefined,
Expand All @@ -62,9 +69,11 @@ describe('getAccessControlConfig', () => {

it('parses, trims, lowercases, and dedupes csv env vars', async () => {
envRef.BLOCKED_SIGNUP_DOMAINS = 'Gmail.com, yahoo.com ,gmail.com,'
envRef.BLOCKED_EMAILS = 'Spam@Evil.com, spam@evil.com'
envRef.ALLOWED_LOGIN_DOMAINS = 'Sim.ai'
const result = await getAccessControlConfig()
expect(result.blockedSignupDomains).toEqual(['gmail.com', 'yahoo.com'])
expect(result.blockedEmails).toEqual(['spam@evil.com'])
expect(result.allowedLoginDomains).toEqual(['sim.ai'])
expect(mockFetch).not.toHaveBeenCalled()
})
Expand Down Expand Up @@ -104,3 +113,71 @@ 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)
})
})

describe('isEmailBlockedByAccessControl', () => {
const config: AccessControlConfig = {
...empty,
blockedSignupDomains: ['bad.com'],
blockedEmails: ['spam@evil.com'],
}

it('matches individually blocked emails case-insensitively', () => {
expect(isEmailBlockedByAccessControl('spam@evil.com', config)).toBe(true)
expect(isEmailBlockedByAccessControl(' Spam@Evil.com ', config)).toBe(true)
expect(isEmailBlockedByAccessControl('other@evil.com', config)).toBe(false)
})

it('matches blocked domains and subdomains', () => {
expect(isEmailBlockedByAccessControl('a@bad.com', config)).toBe(true)
expect(isEmailBlockedByAccessControl('a@mail.bad.com', config)).toBe(true)
expect(isEmailBlockedByAccessControl('a@good.com', config)).toBe(false)
})

it('returns false for missing emails and empty config', () => {
expect(isEmailBlockedByAccessControl(null, config)).toBe(false)
expect(isEmailBlockedByAccessControl(undefined, config)).toBe(false)
expect(isEmailBlockedByAccessControl('a@bad.com', empty)).toBe(false)
})
})
32 changes: 32 additions & 0 deletions apps/sim/lib/auth/access-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,41 @@ const ACCESS_CONTROL_PROFILE = 'access-control'
*/
export interface AccessControlConfig {
blockedSignupDomains: string[]
blockedEmails: string[]
allowedLoginEmails: string[]
allowedLoginDomains: string[]
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}`))
}

/**
* True when the email is individually banned (`blockedEmails`) or its domain
* is in the blocked-domains list. The single predicate for "this email must
* not sign up, sign in, or execute anything".
*/
export function isEmailBlockedByAccessControl(
email: string | undefined | null,
config: AccessControlConfig
): boolean {
if (!email) return false
const normalized = email.trim().toLowerCase()
if (config.blockedEmails.includes(normalized)) return true
return isEmailInDenylist(normalized, config.blockedSignupDomains)
}

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 All @@ -37,6 +67,7 @@ function parseCsv(value: string | undefined): string[] {
function fromEnv(): AccessControlConfig {
return {
blockedSignupDomains: parseCsv(env.BLOCKED_SIGNUP_DOMAINS),
blockedEmails: parseCsv(env.BLOCKED_EMAILS),
allowedLoginEmails: parseCsv(env.ALLOWED_LOGIN_EMAILS),
allowedLoginDomains: parseCsv(env.ALLOWED_LOGIN_DOMAINS),
blockedEmailMxHosts: parseCsv(env.BLOCKED_EMAIL_MX_HOSTS),
Expand All @@ -47,6 +78,7 @@ function parseConfig(json: unknown): AccessControlConfig {
const obj = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
return {
blockedSignupDomains: normalizeList(obj.blockedSignupDomains),
blockedEmails: normalizeList(obj.blockedEmails),
allowedLoginEmails: normalizeList(obj.allowedLoginEmails),
allowedLoginDomains: normalizeList(obj.allowedLoginDomains),
blockedEmailMxHosts: normalizeList(obj.blockedEmailMxHosts),
Expand Down
47 changes: 0 additions & 47 deletions apps/sim/lib/auth/auth.test.ts

This file was deleted.

47 changes: 32 additions & 15 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
renderPasswordResetEmail,
renderWelcomeEmail,
} from '@/components/emails'
import { getAccessControlConfig } from '@/lib/auth/access-control'
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
import { sendPlanWelcomeEmail } from '@/lib/billing'
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
import {
Expand Down Expand Up @@ -139,16 +139,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 @@ -235,8 +225,8 @@ export const auth = betterAuth({
create: {
before: async (user) => {
const accessControl = await getAccessControlConfig()
if (isEmailInDenylist(user.email, accessControl.blockedSignupDomains)) {
throw new Error('Sign-ups from this email domain are not allowed.')
if (isEmailBlockedByAccessControl(user.email, accessControl)) {
throw new Error('Sign-ups from this email are not allowed.')
}
return { data: user }
},
Expand Down Expand Up @@ -593,6 +583,29 @@ export const auth = betterAuth({
session: {
create: {
before: async (session) => {
// Blocked emails/domains 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 ||
accessControl.blockedEmails.length > 0
) {
const [sessionUser] = await db
.select({ email: schema.user.email })
.from(schema.user)
.where(eq(schema.user.id, session.userId))
.limit(1)
if (isEmailBlockedByAccessControl(sessionUser?.email, accessControl)) {
logger.warn('Blocking session creation for blocked 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 @@ -886,9 +899,13 @@ export const auth = betterAuth({
}
}

if (isSignUp && isEmailInDenylist(ctx.body?.email, accessControl.blockedSignupDomains)) {
// Blocked emails/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 (isEmailBlockedByAccessControl(requestEmail, accessControl)) {
throw new APIError('FORBIDDEN', {
message: 'Sign-ups from this email domain are not allowed.',
message: isSignUp
? 'Sign-ups from this email are not allowed.'
: 'Access restricted. Please contact your administrator.',
})
}

Expand Down
Loading
Loading