diff --git a/apps/sim/lib/auth/access-control.test.ts b/apps/sim/lib/auth/access-control.test.ts index 3dee79ab4e7..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 } 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() }) @@ -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) + }) +}) diff --git a/apps/sim/lib/auth/access-control.ts b/apps/sim/lib/auth/access-control.ts index 685b5075790..fe09a2b6737 100644 --- a/apps/sim/lib/auth/access-control.ts +++ b/apps/sim/lib/auth/access-control.ts @@ -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))) @@ -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), @@ -47,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.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 5037e795657..c43a7f56ce5 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -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 { @@ -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 }) ) @@ -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 } }, @@ -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 @@ -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.', }) } diff --git a/apps/sim/lib/auth/ban.test.ts b/apps/sim/lib/auth/ban.test.ts new file mode 100644 index 00000000000..f6e53aa9bb0 --- /dev/null +++ b/apps/sim/lib/auth/ban.test.ts @@ -0,0 +1,131 @@ +/** + * @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, + BLOCKED_EMAILS: 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' + envRef.BLOCKED_EMAILS = 'spam@evil.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 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) + }) + + 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 + envRef.BLOCKED_EMAILS = 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 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([ + { 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..8ca733d2994 --- /dev/null +++ b/apps/sim/lib/auth/ban.ts @@ -0,0 +1,53 @@ +import { db, user } from '@sim/db' +import { inArray, sql } from 'drizzle-orm' +import { getAccessControlConfig, isEmailBlockedByAccessControl } 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: 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 (isEmailBlockedByAccessControl(email, accessControl)) 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 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 { + 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) || 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 7ba9c92511d..c01826cdfee 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -28,6 +28,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. diff --git a/apps/sim/lib/execution/preprocessing.test.ts b/apps/sim/lib/execution/preprocessing.test.ts index 326a5f9f07f..c00e5653ec5 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(), @@ -32,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, }), @@ -144,3 +151,89 @@ 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, 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', + 'creator-1', + ]) + }) + + 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', 'creator-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 0ff0d9eba5d..075ee57af90 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, @@ -321,6 +322,75 @@ export async function preprocessExecution( } } + // ========== STEP 3.5: Reject Banned Accounts ========== + // 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) { + 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..1e259072117 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, 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' @@ -24,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') @@ -93,6 +95,43 @@ export async function executeInboxTask(taskId: string): Promise { return } + // 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, 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, billedAccountUserId] = await Promise.all([ + isEmailBlocked(inboxTask.fromEmail), + getWorkspaceBilledAccountUserId(ws.id), + ]) + 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' + } + } 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 + } + if (!chatId) { const chatResult = await resolveOrCreateChat({ userId,