From 21d7927c41eab6df3fc69516b5916f9f520b21de Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 10 Jun 2026 12:55:12 -0700 Subject: [PATCH 1/6] feat(auth): enforce domain and account bans on sign-in and workflow executions --- apps/sim/hooks/use-inline-rename.ts | 2 +- apps/sim/lib/auth/access-control.test.ts | 44 ++++++++- apps/sim/lib/auth/access-control.ts | 14 +++ apps/sim/lib/auth/auth.test.ts | 47 ---------- apps/sim/lib/auth/auth.ts | 40 +++++--- apps/sim/lib/auth/ban.test.ts | 87 ++++++++++++++++++ apps/sim/lib/auth/ban.ts | 38 ++++++++ apps/sim/lib/execution/preprocessing.test.ts | 96 +++++++++++++++++++- apps/sim/lib/execution/preprocessing.ts | 66 ++++++++++++++ apps/sim/lib/mothership/inbox/executor.ts | 9 ++ 10 files changed, 377 insertions(+), 66 deletions(-) delete mode 100644 apps/sim/lib/auth/auth.test.ts create mode 100644 apps/sim/lib/auth/ban.test.ts create mode 100644 apps/sim/lib/auth/ban.ts diff --git a/apps/sim/hooks/use-inline-rename.ts b/apps/sim/hooks/use-inline-rename.ts index 87c819e1a3b..ec6672cbc7e 100644 --- a/apps/sim/hooks/use-inline-rename.ts +++ b/apps/sim/hooks/use-inline-rename.ts @@ -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 + onSave: (id: string, newName: string) => unknown } /** diff --git a/apps/sim/lib/auth/access-control.test.ts b/apps/sim/lib/auth/access-control.test.ts index 3dee79ab4e7..bb45b22be5c 100644 --- a/apps/sim/lib/auth/access-control.test.ts +++ b/apps/sim/lib/auth/access-control.test.ts @@ -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: [], @@ -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) + }) +}) diff --git a/apps/sim/lib/auth/access-control.ts b/apps/sim/lib/auth/access-control.ts index 685b5075790..5822fd60426 100644 --- a/apps/sim/lib/auth/access-control.ts +++ b/apps/sim/lib/auth/access-control.ts @@ -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))) diff --git a/apps/sim/lib/auth/auth.test.ts b/apps/sim/lib/auth/auth.test.ts deleted file mode 100644 index d060b44f779..00000000000 --- a/apps/sim/lib/auth/auth.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { isEmailInDenylist } from '@/lib/auth/auth' - -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) - }) -}) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 7a65dfa10eb..86ff9548d6a 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -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 { @@ -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 }) ) @@ -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 @@ -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.', }) } diff --git a/apps/sim/lib/auth/ban.test.ts b/apps/sim/lib/auth/ban.test.ts new file mode 100644 index 00000000000..cff03bd1fe0 --- /dev/null +++ b/apps/sim/lib/auth/ban.test.ts @@ -0,0 +1,87 @@ +/** + * @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() })) +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 } 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('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') + }) +}) diff --git a/apps/sim/lib/auth/ban.ts b/apps/sim/lib/auth/ban.ts new file mode 100644 index 00000000000..9a9a005c017 --- /dev/null +++ b/apps/sim/lib/auth/ban.ts @@ -0,0 +1,38 @@ +import { db, user } from '@sim/db' +import { inArray } 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 +} + +/** + * 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 { + 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) +} diff --git a/apps/sim/lib/execution/preprocessing.test.ts b/apps/sim/lib/execution/preprocessing.test.ts index 326a5f9f07f..49fd1bb1638 100644 --- a/apps/sim/lib/execution/preprocessing.test.ts +++ b/apps/sim/lib/execution/preprocessing.test.ts @@ -5,15 +5,21 @@ import { loggingSessionMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetWorkspaceBilledAccountUserId, mockCheckRateLimit } = vi.hoisted(() => ({ - mockGetWorkspaceBilledAccountUserId: vi.fn(), - mockCheckRateLimit: vi.fn(), -})) +const { mockGetWorkspaceBilledAccountUserId, mockCheckRateLimit, mockGetActivelyBannedUserIds } = + vi.hoisted(() => ({ + mockGetWorkspaceBilledAccountUserId: vi.fn(), + mockCheckRateLimit: vi.fn(), + mockGetActivelyBannedUserIds: vi.fn().mockResolvedValue([]), + })) vi.mock('@sim/db', () => ({ db: {} })) vi.mock('drizzle-orm', () => ({ eq: vi.fn() })) +vi.mock('@/lib/auth/ban', () => ({ + getActivelyBannedUserIds: mockGetActivelyBannedUserIds, +})) vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ checkServerSideUsageLimits: vi.fn(), + checkOrgMemberUsageLimit: vi.fn().mockResolvedValue({ isExceeded: false }), })) vi.mock('@/lib/billing/core/subscription', () => ({ getHighestPrioritySubscription: vi.fn(), @@ -144,3 +150,85 @@ describe('preprocessExecution logPreprocessingErrors option', () => { expect(loggingSession.safeStart).not.toHaveBeenCalled() }) }) + +describe('preprocessExecution ban gate', () => { + const baseOptions = { + workflowId: 'workflow-1', + userId: 'owner-1', + triggerType: 'workflow' as const, + executionId: 'execution-1', + requestId: 'request-1', + checkDeployment: false, + checkRateLimit: false, + workflowRecord: { id: 'workflow-1', workspaceId: 'workspace-1', isDeployed: true } as any, + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-account-1') + mockGetActivelyBannedUserIds.mockResolvedValue([]) + vi.mocked(getHighestPrioritySubscription).mockResolvedValue({ plan: 'free' } as any) + vi.mocked(checkServerSideUsageLimits).mockResolvedValue({ + isExceeded: false, + currentUsage: 1, + limit: 10, + } as any) + }) + + it('blocks execution with 403 when the actor is banned, before any billing queries', async () => { + mockGetActivelyBannedUserIds.mockResolvedValue(['billed-account-1']) + + const loggingSession = { + safeStart: vi.fn().mockResolvedValue(true), + safeCompleteWithError: vi.fn().mockResolvedValue(undefined), + } + + const result = await preprocessExecution({ + ...baseOptions, + loggingSession: loggingSession as any, + }) + + expect(result).toMatchObject({ + success: false, + error: { statusCode: 403, logCreated: true, message: 'Account suspended' }, + }) + expect(loggingSession.safeStart).toHaveBeenCalled() + expect(getHighestPrioritySubscription).not.toHaveBeenCalled() + expect(checkServerSideUsageLimits).not.toHaveBeenCalled() + }) + + it('checks the billing actor and the caller-provided userId in one call', async () => { + const result = await preprocessExecution(baseOptions) + + expect(result.success).toBe(true) + expect(mockGetActivelyBannedUserIds).toHaveBeenCalledTimes(1) + expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1', 'owner-1']) + }) + + it('excludes the "unknown" sentinel userId from the ban check', async () => { + const result = await preprocessExecution({ ...baseOptions, userId: 'unknown' }) + + expect(result.success).toBe(true) + expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1']) + }) + + it('fails closed with 500 when the ban check errors', async () => { + mockGetActivelyBannedUserIds.mockRejectedValue(new Error('db down')) + + const loggingSession = { + safeStart: vi.fn().mockResolvedValue(true), + safeCompleteWithError: vi.fn().mockResolvedValue(undefined), + } + + const result = await preprocessExecution({ + ...baseOptions, + loggingSession: loggingSession as any, + }) + + expect(result).toMatchObject({ + success: false, + error: { statusCode: 500, logCreated: true }, + }) + expect(checkServerSideUsageLimits).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts index 833d7b9ab17..836e7aba8fb 100644 --- a/apps/sim/lib/execution/preprocessing.ts +++ b/apps/sim/lib/execution/preprocessing.ts @@ -1,6 +1,7 @@ import type { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getActiveWorkflowRecord } from '@sim/workflow-authz' +import { getActivelyBannedUserIds } from '@/lib/auth/ban' import { checkOrgMemberUsageLimit, checkServerSideUsageLimits, @@ -311,6 +312,71 @@ export async function preprocessExecution( } } + // ========== STEP 3.5: Reject Banned Accounts ========== + // Blocks executions when the billing actor — or, when different, the + // caller-provided userId (workflow creator, chat deployer, authenticated + // caller) — has an active ban or a blocked email domain. + const banCandidateIds = [actorUserId] + if (userId && userId !== 'unknown' && userId !== actorUserId) { + banCandidateIds.push(userId) + } + try { + const bannedUserIds = await getActivelyBannedUserIds(banCandidateIds) + if (bannedUserIds.length > 0) { + logger.warn(`[${requestId}] Execution blocked: banned account`, { + workflowId, + bannedUserIds, + triggerType, + }) + + await recordPreprocessingError({ + workflowId, + executionId, + triggerType, + requestId, + userId: actorUserId, + workspaceId, + errorMessage: 'This account has been suspended. Workflow executions are blocked.', + loggingSession: providedLoggingSession, + triggerData, + }) + + return { + success: false, + error: { + message: 'Account suspended', + statusCode: 403, + logCreated: true, + }, + } + } + } catch (error) { + logger.error(`[${requestId}] Error checking account ban status`, { error, actorUserId }) + + await recordPreprocessingError({ + workflowId, + executionId, + triggerType, + requestId, + userId: actorUserId, + workspaceId, + errorMessage: 'Unable to verify account status. Execution blocked for security.', + loggingSession: providedLoggingSession, + triggerData, + }) + + return { + success: false, + error: { + message: 'Unable to verify account status. Execution blocked for security.', + statusCode: 500, + logCreated: true, + retryable: isRetryableInfrastructureError(error), + cause: describeRetryableInfrastructureError(error), + }, + } + } + // ========== STEP 4: Get Subscription ========== const userSubscription = await getHighestPrioritySubscription(actorUserId) diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index db9583a8fe2..e419972bb40 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' +import { getActivelyBannedUserIds } from '@/lib/auth/ban' import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' @@ -93,6 +94,14 @@ export async function executeInboxTask(taskId: string): Promise { return } + // No email response on purpose — never mail a suspended account. + const [bannedUserId] = await getActivelyBannedUserIds([userId]) + if (bannedUserId) { + logger.warn('Blocking inbox task: resolved user is banned', { taskId, userId }) + await markTaskFailed(taskId, 'User account is suspended') + return + } + if (!chatId) { const chatResult = await resolveOrCreateChat({ userId, From 3188ee913b8979802bdaf935e58e00c9bbb51372 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 10 Jun 2026 13:04:34 -0700 Subject: [PATCH 2/6] fix(mothership): fail inbox ban checks closed without emailing, gate blocked senders --- apps/sim/lib/auth/ban.test.ts | 19 +++++++++++- apps/sim/lib/auth/ban.ts | 10 +++++++ apps/sim/lib/mothership/inbox/executor.ts | 35 +++++++++++++++++++---- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/auth/ban.test.ts b/apps/sim/lib/auth/ban.test.ts index cff03bd1fe0..4a5c8e7bc7e 100644 --- a/apps/sim/lib/auth/ban.test.ts +++ b/apps/sim/lib/auth/ban.test.ts @@ -21,7 +21,7 @@ vi.mock('@/lib/core/config/env', () => ({ })) vi.mock('@/lib/core/config/feature-flags', () => ({ isAppConfigEnabled: false })) -import { getActivelyBannedUserIds, isBanActive } from '@/lib/auth/ban' +import { getActivelyBannedUserIds, isBanActive, isEmailDomainBlocked } from '@/lib/auth/ban' describe('isBanActive', () => { it('returns true for a permanent ban', () => { @@ -42,6 +42,23 @@ describe('isBanActive', () => { }) }) +describe('isEmailDomainBlocked', () => { + beforeEach(() => { + envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com' + }) + + it('returns true for blocked domains and subdomains', async () => { + expect(await isEmailDomainBlocked('a@bad.com')).toBe(true) + expect(await isEmailDomainBlocked('a@mail.bad.com')).toBe(true) + }) + + it('returns false for clean domains and missing emails', async () => { + expect(await isEmailDomainBlocked('a@good.com')).toBe(false) + expect(await isEmailDomainBlocked(null)).toBe(false) + expect(await isEmailDomainBlocked(undefined)).toBe(false) + }) +}) + describe('getActivelyBannedUserIds', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/auth/ban.ts b/apps/sim/lib/auth/ban.ts index 9a9a005c017..a3fb535e16c 100644 --- a/apps/sim/lib/auth/ban.ts +++ b/apps/sim/lib/auth/ban.ts @@ -12,6 +12,16 @@ export function isBanActive(row: { banned: boolean | null; banExpires: Date | nu return true } +/** + * True when the email's domain is in the appconfig blocked-domains list. + * For gating raw emails (e.g. inbound senders) that have no user row. + */ +export async function isEmailDomainBlocked(email: string | null | undefined): Promise { + if (!email) return false + const accessControl = await getAccessControlConfig() + return isEmailInDenylist(email, accessControl.blockedSignupDomains) +} + /** * 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 diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index e419972bb40..e928a1e87aa 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' -import { getActivelyBannedUserIds } from '@/lib/auth/ban' +import { getActivelyBannedUserIds, isEmailDomainBlocked } from '@/lib/auth/ban' import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' @@ -94,11 +94,34 @@ export async function executeInboxTask(taskId: string): Promise { return } - // No email response on purpose — never mail a suspended account. - const [bannedUserId] = await getActivelyBannedUserIds([userId]) - if (bannedUserId) { - logger.warn('Blocking inbox task: resolved user is banned', { taskId, userId }) - await markTaskFailed(taskId, 'User account is suspended') + // Blocked senders and banned accounts must not drive the agent; the sender + // email is checked directly because non-members resolve to the workspace + // owner. Fails closed on lookup errors. No email response in any of these + // paths — never mail a suspended account. + let blockReason: string | null = null + try { + const [senderBlocked, [bannedUserId]] = await Promise.all([ + isEmailDomainBlocked(inboxTask.fromEmail), + getActivelyBannedUserIds([userId]), + ]) + if (senderBlocked || bannedUserId) { + logger.warn('Blocking inbox task: sender or resolved user is banned', { + taskId, + userId, + senderBlocked, + }) + blockReason = 'User account is suspended' + } + } catch (error) { + logger.error('Inbox task ban check failed; failing closed', { + taskId, + error: getErrorMessage(error, 'Unknown error'), + }) + blockReason = 'Unable to verify account status' + } + if (blockReason) { + responseSent = true + await markTaskFailed(taskId, blockReason) return } From 590653e2682dc1e77f4eaef11b31969c00f866e9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 10 Jun 2026 13:26:36 -0700 Subject: [PATCH 3/6] fix(mothership): also block inbox senders whose own account is banned --- apps/sim/lib/auth/ban.test.ts | 28 +++++++++++++++-------- apps/sim/lib/auth/ban.ts | 16 +++++++++---- apps/sim/lib/mothership/inbox/executor.ts | 11 +++++---- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/apps/sim/lib/auth/ban.test.ts b/apps/sim/lib/auth/ban.test.ts index 4a5c8e7bc7e..6b43bcad8cc 100644 --- a/apps/sim/lib/auth/ban.test.ts +++ b/apps/sim/lib/auth/ban.test.ts @@ -12,7 +12,7 @@ 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() })) +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() { @@ -21,7 +21,7 @@ vi.mock('@/lib/core/config/env', () => ({ })) vi.mock('@/lib/core/config/feature-flags', () => ({ isAppConfigEnabled: false })) -import { getActivelyBannedUserIds, isBanActive, isEmailDomainBlocked } from '@/lib/auth/ban' +import { getActivelyBannedUserIds, isBanActive, isEmailBlocked } from '@/lib/auth/ban' describe('isBanActive', () => { it('returns true for a permanent ban', () => { @@ -42,20 +42,28 @@ describe('isBanActive', () => { }) }) -describe('isEmailDomainBlocked', () => { +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 for blocked domains and subdomains', async () => { - expect(await isEmailDomainBlocked('a@bad.com')).toBe(true) - expect(await isEmailDomainBlocked('a@mail.bad.com')).toBe(true) + 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 domains and missing emails', async () => { - expect(await isEmailDomainBlocked('a@good.com')).toBe(false) - expect(await isEmailDomainBlocked(null)).toBe(false) - expect(await isEmailDomainBlocked(undefined)).toBe(false) + 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) }) }) diff --git a/apps/sim/lib/auth/ban.ts b/apps/sim/lib/auth/ban.ts index a3fb535e16c..eb2356bb0bc 100644 --- a/apps/sim/lib/auth/ban.ts +++ b/apps/sim/lib/auth/ban.ts @@ -1,5 +1,5 @@ import { db, user } from '@sim/db' -import { inArray } from 'drizzle-orm' +import { inArray, sql } from 'drizzle-orm' import { getAccessControlConfig, isEmailInDenylist } from '@/lib/auth/access-control' /** @@ -13,13 +13,19 @@ export function isBanActive(row: { banned: boolean | null; banExpires: Date | nu } /** - * True when the email's domain is in the appconfig blocked-domains list. - * For gating raw emails (e.g. inbound senders) that have no user row. + * 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 isEmailDomainBlocked(email: string | null | undefined): Promise { +export async function isEmailBlocked(email: string | null | undefined): Promise { if (!email) return false const accessControl = await getAccessControlConfig() - return isEmailInDenylist(email, accessControl.blockedSignupDomains) + 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) } /** diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index e928a1e87aa..6a19eb5b86a 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' -import { getActivelyBannedUserIds, isEmailDomainBlocked } from '@/lib/auth/ban' +import { getActivelyBannedUserIds, isEmailBlocked } from '@/lib/auth/ban' import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' @@ -95,13 +95,14 @@ export async function executeInboxTask(taskId: string): Promise { } // Blocked senders and banned accounts must not drive the agent; the sender - // email is checked directly because non-members resolve to the workspace - // owner. Fails closed on lookup errors. No email response in any of these - // paths — never mail a suspended account. + // email is checked directly (domain list + the sender's own account ban) + // because non-members resolve to the workspace owner. Fails closed on + // lookup errors. No email response in any of these paths — never mail a + // suspended account. let blockReason: string | null = null try { const [senderBlocked, [bannedUserId]] = await Promise.all([ - isEmailDomainBlocked(inboxTask.fromEmail), + isEmailBlocked(inboxTask.fromEmail), getActivelyBannedUserIds([userId]), ]) if (senderBlocked || bannedUserId) { From b41c0fecaa87c141f228e18be930c99940a30358 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 10 Jun 2026 13:39:18 -0700 Subject: [PATCH 4/6] fix(execution): always ban-check the workflow owner so schedules are covered --- apps/sim/lib/execution/preprocessing.test.ts | 13 +++++++++---- apps/sim/lib/execution/preprocessing.ts | 10 +++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/execution/preprocessing.test.ts b/apps/sim/lib/execution/preprocessing.test.ts index 49fd1bb1638..c00e5653ec5 100644 --- a/apps/sim/lib/execution/preprocessing.test.ts +++ b/apps/sim/lib/execution/preprocessing.test.ts @@ -38,6 +38,7 @@ vi.mock('@/lib/workspaces/utils', () => ({ vi.mock('@sim/workflow-authz', () => ({ getActiveWorkflowRecord: vi.fn().mockResolvedValue({ id: 'workflow-1', + userId: 'creator-1', workspaceId: 'workspace-1', isDeployed: true, }), @@ -197,19 +198,23 @@ describe('preprocessExecution ban gate', () => { expect(checkServerSideUsageLimits).not.toHaveBeenCalled() }) - it('checks the billing actor and the caller-provided userId in one call', async () => { + it('checks the billing actor, caller-provided userId, and workflow owner in one call', async () => { const result = await preprocessExecution(baseOptions) expect(result.success).toBe(true) expect(mockGetActivelyBannedUserIds).toHaveBeenCalledTimes(1) - expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1', 'owner-1']) + expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith([ + 'billed-account-1', + 'owner-1', + 'creator-1', + ]) }) - it('excludes the "unknown" sentinel userId from the ban check', async () => { + it('excludes the "unknown" sentinel userId but still checks the workflow owner', async () => { const result = await preprocessExecution({ ...baseOptions, userId: 'unknown' }) expect(result.success).toBe(true) - expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1']) + expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1', 'creator-1']) }) it('fails closed with 500 when the ban check errors', async () => { diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts index 836e7aba8fb..e47b4123d11 100644 --- a/apps/sim/lib/execution/preprocessing.ts +++ b/apps/sim/lib/execution/preprocessing.ts @@ -313,13 +313,17 @@ export async function preprocessExecution( } // ========== STEP 3.5: Reject Banned Accounts ========== - // Blocks executions when the billing actor — or, when different, the - // caller-provided userId (workflow creator, chat deployer, authenticated - // caller) — has an active ban or a blocked email domain. + // Blocks executions when the billing actor, the workflow owner, or the + // caller-provided userId (chat deployer, authenticated caller) has an + // active ban or a blocked email domain. The owner comes from the workflow + // record so schedules — which pass the 'unknown' sentinel — are covered. const banCandidateIds = [actorUserId] if (userId && userId !== 'unknown' && userId !== actorUserId) { banCandidateIds.push(userId) } + if (workflowRecord.userId && !banCandidateIds.includes(workflowRecord.userId)) { + banCandidateIds.push(workflowRecord.userId) + } try { const bannedUserIds = await getActivelyBannedUserIds(banCandidateIds) if (bannedUserIds.length > 0) { From 56d974f97d90c54fe62d1b2bb1a71bb19023a1ff Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 11 Jun 2026 10:34:48 -0700 Subject: [PATCH 5/6] feat(auth): support banning specific emails via appconfig blockedEmails list --- apps/sim/lib/auth/access-control.test.ts | 37 +++++++++++++++++++++++- apps/sim/lib/auth/access-control.ts | 18 ++++++++++++ apps/sim/lib/auth/auth.ts | 25 +++++++++------- apps/sim/lib/auth/ban.test.ts | 21 +++++++++++++- apps/sim/lib/auth/ban.ts | 19 ++++++------ apps/sim/lib/core/config/env.ts | 1 + 6 files changed, 98 insertions(+), 23 deletions(-) diff --git a/apps/sim/lib/auth/access-control.test.ts b/apps/sim/lib/auth/access-control.test.ts index bb45b22be5c..8e685d3f7ac 100644 --- a/apps/sim/lib/auth/access-control.test.ts +++ b/apps/sim/lib/auth/access-control.test.ts @@ -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, @@ -33,10 +34,15 @@ vi.mock('@/lib/core/config/feature-flags', () => ({ }, })) -import { getAccessControlConfig, isEmailInDenylist } from '@/lib/auth/access-control' +import { + getAccessControlConfig, + isEmailBlockedByAccessControl, + isEmailInDenylist, +} from '@/lib/auth/access-control' const empty: AccessControlConfig = { blockedSignupDomains: [], + blockedEmails: [], allowedLoginEmails: [], allowedLoginDomains: [], blockedEmailMxHosts: [], @@ -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, @@ -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() }) @@ -146,3 +155,29 @@ describe('isEmailInDenylist', () => { 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) + }) +}) diff --git a/apps/sim/lib/auth/access-control.ts b/apps/sim/lib/auth/access-control.ts index 5822fd60426..fe09a2b6737 100644 --- a/apps/sim/lib/auth/access-control.ts +++ b/apps/sim/lib/auth/access-control.ts @@ -16,6 +16,7 @@ const ACCESS_CONTROL_PROFILE = 'access-control' */ export interface AccessControlConfig { blockedSignupDomains: string[] + blockedEmails: string[] allowedLoginEmails: string[] allowedLoginDomains: string[] blockedEmailMxHosts: string[] @@ -35,6 +36,21 @@ export function isEmailInDenylist( 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))) @@ -51,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), @@ -61,6 +78,7 @@ function parseConfig(json: unknown): AccessControlConfig { const obj = (json && typeof json === 'object' ? json : {}) as Record return { blockedSignupDomains: normalizeList(obj.blockedSignupDomains), + blockedEmails: normalizeList(obj.blockedEmails), allowedLoginEmails: normalizeList(obj.allowedLoginEmails), allowedLoginDomains: normalizeList(obj.allowedLoginDomains), blockedEmailMxHosts: normalizeList(obj.blockedEmailMxHosts), diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 86ff9548d6a..63f31eec477 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -30,7 +30,7 @@ import { renderPasswordResetEmail, renderWelcomeEmail, } from '@/components/emails' -import { getAccessControlConfig, isEmailInDenylist } 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 { @@ -224,8 +224,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 } }, @@ -582,18 +582,21 @@ export const auth = betterAuth({ session: { create: { before: async (session) => { - // Blocked-domain accounts must not establish sessions, regardless of + // 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) { + 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 (isEmailInDenylist(sessionUser?.email, accessControl.blockedSignupDomains)) { - logger.warn('Blocking session creation for blocked-domain account', { + if (isEmailBlockedByAccessControl(sessionUser?.email, accessControl)) { + logger.warn('Blocking session creation for blocked account', { userId: session.userId, }) throw new APIError('FORBIDDEN', { @@ -836,12 +839,12 @@ export const auth = betterAuth({ } } - // 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)) { + // 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: isSignUp - ? 'Sign-ups from this email domain are not allowed.' + ? 'Sign-ups from this email are not allowed.' : 'Access restricted. Please contact your administrator.', }) } diff --git a/apps/sim/lib/auth/ban.test.ts b/apps/sim/lib/auth/ban.test.ts index 6b43bcad8cc..f6e53aa9bb0 100644 --- a/apps/sim/lib/auth/ban.test.ts +++ b/apps/sim/lib/auth/ban.test.ts @@ -5,7 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockWhere, envRef } = vi.hoisted(() => ({ mockWhere: vi.fn(), - envRef: { BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined }, + envRef: { + BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined, + BLOCKED_EMAILS: undefined as string | undefined, + }, })) vi.mock('@sim/db', () => ({ @@ -46,6 +49,7 @@ describe('isEmailBlocked', () => { beforeEach(() => { vi.clearAllMocks() envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com' + envRef.BLOCKED_EMAILS = 'spam@evil.com' mockWhere.mockResolvedValue([]) }) @@ -55,6 +59,11 @@ describe('isEmailBlocked', () => { expect(mockWhere).not.toHaveBeenCalled() }) + it('returns true for individually blocked emails without querying users', async () => { + expect(await isEmailBlocked('spam@evil.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) @@ -71,6 +80,7 @@ describe('getActivelyBannedUserIds', () => { beforeEach(() => { vi.clearAllMocks() envRef.BLOCKED_SIGNUP_DOMAINS = undefined + envRef.BLOCKED_EMAILS = undefined mockWhere.mockResolvedValue([]) }) @@ -95,6 +105,15 @@ describe('getActivelyBannedUserIds', () => { expect(await getActivelyBannedUserIds(['u1'])).toEqual([]) }) + it('returns ids whose email is individually blocked', async () => { + envRef.BLOCKED_EMAILS = 'spam@evil.com' + mockWhere.mockResolvedValue([ + { id: 'u1', email: 'spam@evil.com', banned: false, banExpires: null }, + { id: 'u2', email: 'ok@evil.com', banned: false, banExpires: null }, + ]) + expect(await getActivelyBannedUserIds(['u1', 'u2'])).toEqual(['u1']) + }) + it('returns ids whose email domain is in the blocked-domains list, including subdomains', async () => { envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com' mockWhere.mockResolvedValue([ diff --git a/apps/sim/lib/auth/ban.ts b/apps/sim/lib/auth/ban.ts index eb2356bb0bc..8ca733d2994 100644 --- a/apps/sim/lib/auth/ban.ts +++ b/apps/sim/lib/auth/ban.ts @@ -1,6 +1,6 @@ import { db, user } from '@sim/db' import { inArray, sql } from 'drizzle-orm' -import { getAccessControlConfig, isEmailInDenylist } from '@/lib/auth/access-control' +import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' /** * True when a ban is currently in effect. Mirrors better-auth admin-plugin @@ -13,14 +13,15 @@ export function isBanActive(row: { banned: boolean | null; banExpires: Date | nu } /** - * 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. + * True when a raw email (e.g. an inbound sender) is blocked: it is in the + * appconfig blocked-emails list, its domain is in the 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 { if (!email) return false const accessControl = await getAccessControlConfig() - if (isEmailInDenylist(email, accessControl.blockedSignupDomains)) return true + if (isEmailBlockedByAccessControl(email, accessControl)) return true const rows = await db .select({ banned: user.banned, banExpires: user.banExpires }) .from(user) @@ -30,8 +31,8 @@ export async function isEmailBlocked(email: string | null | undefined): Promise< /** * 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 + * active account ban, or an email/domain in the appconfig blocked lists. + * One user query plus the cached access-control fetch. Throws on db * failure — callers must fail closed. */ export async function getActivelyBannedUserIds(userIds: string[]): Promise { @@ -47,8 +48,6 @@ export async function getActivelyBannedUserIds(userIds: string[]): Promise isBanActive(row) || isEmailInDenylist(row.email, accessControl.blockedSignupDomains) - ) + .filter((row) => isBanActive(row) || isEmailBlockedByAccessControl(row.email, accessControl)) .map((row) => row.id) } diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index a4fd479f447..f82614ba918 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -27,6 +27,7 @@ export const env = createEnv({ ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com") + BLOCKED_EMAILS: z.string().optional(), // Comma-separated list of specific email addresses banned from the platform (signup, sign-in, executions) SIGNUP_MX_VALIDATION_ENABLED: z.boolean().optional(), // Opt-in: validate the email's MX backend at signup (blocks no-MX domains and denylisted shared spam backends). Off by default; enable on hosted/abuse-targeted deployments. BLOCKED_EMAIL_MX_HOSTS: z.string().optional(), // Comma-separated MX-host substrings blocked from signing up; matched against the domain's resolved MX backend to catch throwaway domains that share a mail backend. No defaults — operators supply their own list. Only used when SIGNUP_MX_VALIDATION_ENABLED is set. TRUSTED_ORIGINS: z.string().optional(), // Comma-separated additional origins to trust for auth (e.g., "https://app.example.com,https://www.example.com"). Merged into Better Auth trustedOrigins. From f460845cd13fd5ed4198b96e8ca365b76fa0c3f4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 11 Jun 2026 10:49:00 -0700 Subject: [PATCH 6/6] fix(mothership): ban-check the workspace billed account on inbox tasks --- apps/sim/lib/mothership/inbox/executor.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 6a19eb5b86a..1e259072117 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -25,6 +25,7 @@ import { buildUserSkillTool } from '@/lib/mothership/skills' import { uploadFile } from '@/lib/uploads/core/storage-service' import { createFileContent, type MessageContent } from '@/lib/uploads/utils/file-utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' const logger = createLogger('InboxExecutor') @@ -96,20 +97,25 @@ export async function executeInboxTask(taskId: string): Promise { // Blocked senders and banned accounts must not drive the agent; the sender // email is checked directly (domain list + the sender's own account ban) - // because non-members resolve to the workspace owner. Fails closed on - // lookup errors. No email response in any of these paths — never mail a - // suspended account. + // because non-members resolve to the workspace owner, and the workspace + // billed account is checked to match preprocessExecution's gate. Fails + // closed on lookup errors. No email response in any of these paths — + // never mail a suspended account. let blockReason: string | null = null try { - const [senderBlocked, [bannedUserId]] = await Promise.all([ + const [senderBlocked, billedAccountUserId] = await Promise.all([ isEmailBlocked(inboxTask.fromEmail), - getActivelyBannedUserIds([userId]), + getWorkspaceBilledAccountUserId(ws.id), ]) - if (senderBlocked || bannedUserId) { - logger.warn('Blocking inbox task: sender or resolved user is banned', { + const bannedUserIds = await getActivelyBannedUserIds( + billedAccountUserId ? [userId, billedAccountUserId] : [userId] + ) + if (senderBlocked || bannedUserIds.length > 0) { + logger.warn('Blocking inbox task: sender, resolved user, or billed account is banned', { taskId, userId, senderBlocked, + bannedUserIds, }) blockReason = 'User account is suspended' }