diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index faffc248668..e84123de557 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -78,6 +78,7 @@ import { isOrganizationsEnabled, isRegistrationDisabled, isSignupEmailValidationEnabled, + isSignupMxValidationEnabled, } from '@/lib/core/config/feature-flags' import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' @@ -85,6 +86,7 @@ import { processCredentialDraft } from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server' import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle' import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' @@ -843,6 +845,15 @@ export const auth = betterAuth({ }) } + if (isSignupMxValidationEnabled && ctx.path.startsWith('/sign-up/email') && ctx.body?.email) { + const mxCheck = await validateSignupEmailMx(ctx.body.email) + if (!mxCheck.allowed) { + throw new APIError('FORBIDDEN', { + message: 'Sign-ups from this email domain are not allowed.', + }) + } + } + if (ctx.path === '/oauth2/authorize' || ctx.path === '/oauth2/token') { const clientId = (ctx.query?.client_id ?? ctx.body?.client_id) as string | undefined if (clientId && isMetadataUrl(clientId)) { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index a3189f679d0..3b871859295 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -27,6 +27,8 @@ 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") + 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. TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification SIGNUP_EMAIL_VALIDATION_ENABLED: z.boolean().optional(), // Enable disposable email blocking via better-auth-harmony (55K+ domains) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index f1d6e959f36..bde9252d652 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -81,6 +81,13 @@ export const isEmailPasswordEnabled = !isFalsy(env.EMAIL_PASSWORD_SIGNUP_ENABLED */ export const isSignupEmailValidationEnabled = isTruthy(env.SIGNUP_EMAIL_VALIDATION_ENABLED) +/** + * Is MX-based signup validation enabled (blocks no-MX domains and denylisted shared spam + * mail backends). Opt-in to avoid adding a DNS dependency or blocking legitimate signups on + * self-hosted deployments with non-standard mail setups; enable on abuse-targeted deployments. + */ +export const isSignupMxValidationEnabled = isTruthy(env.SIGNUP_MX_VALIDATION_ENABLED) + /** * Is Trigger.dev enabled for async job processing */ diff --git a/apps/sim/lib/messaging/email/validation.server.test.ts b/apps/sim/lib/messaging/email/validation.server.test.ts new file mode 100644 index 00000000000..9fcfb4de6d4 --- /dev/null +++ b/apps/sim/lib/messaging/email/validation.server.test.ts @@ -0,0 +1,90 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveMx, envRef } = vi.hoisted(() => ({ + mockResolveMx: vi.fn(), + envRef: { + BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined, + }, +})) + +vi.mock('dns/promises', () => ({ + default: { resolveMx: mockResolveMx }, +})) + +vi.mock('@/lib/core/config/env', () => ({ + get env() { + return envRef + }, +})) + +import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server' + +const mx = (...hosts: string[]) => + hosts.map((exchange, i) => ({ exchange, priority: (i + 1) * 10 })) + +describe('validateSignupEmailMx', () => { + beforeEach(() => { + vi.clearAllMocks() + envRef.BLOCKED_EMAIL_MX_HOSTS = undefined + }) + + it('blocks a domain whose MX backend is on the configured denylist', async () => { + envRef.BLOCKED_EMAIL_MX_HOSTS = 'blocked-backend.example' + mockResolveMx.mockResolvedValue(mx('smtp.blocked-backend.example')) + const result = await validateSignupEmailMx('user@rotated-domain.test') + expect(result.allowed).toBe(false) + expect(result.reason).toBe('blocked_mx_backend') + }) + + it('matches the denylist as a case-insensitive substring of the MX exchange', async () => { + envRef.BLOCKED_EMAIL_MX_HOSTS = 'Blocked-Backend.Example' + mockResolveMx.mockResolvedValue(mx('mx1.blocked-backend.example')) + const result = await validateSignupEmailMx('user@another-domain.test') + expect(result.allowed).toBe(false) + expect(result.reason).toBe('blocked_mx_backend') + }) + + it('does not block any backend when the denylist is empty (no hardcoded defaults)', async () => { + envRef.BLOCKED_EMAIL_MX_HOSTS = undefined + mockResolveMx.mockResolvedValue(mx('smtp.blocked-backend.example')) + const result = await validateSignupEmailMx('user@rotated-domain.test') + expect(result.allowed).toBe(true) + }) + + it('allows a legitimate domain (gmail)', async () => { + mockResolveMx.mockResolvedValue( + mx('gmail-smtp-in.l.google.com', 'alt1.gmail-smtp-in.l.google.com') + ) + const result = await validateSignupEmailMx('real.person@gmail.com') + expect(result.allowed).toBe(true) + }) + + it('blocks a domain with no MX records (ENOTFOUND)', async () => { + mockResolveMx.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOTFOUND' })) + const result = await validateSignupEmailMx('x@no-such-domain.invalid') + expect(result.allowed).toBe(false) + expect(result.reason).toBe('no_mx') + }) + + it('blocks a domain that resolves to an empty MX set', async () => { + mockResolveMx.mockResolvedValue([]) + const result = await validateSignupEmailMx('x@empty.example') + expect(result.allowed).toBe(false) + expect(result.reason).toBe('no_mx') + }) + + it('fails open on a transient DNS error (does not block legit users)', async () => { + mockResolveMx.mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' })) + const result = await validateSignupEmailMx('user@some-real-domain.com') + expect(result.allowed).toBe(true) + }) + + it('allows when the email has no domain (defers to other validation)', async () => { + const result = await validateSignupEmailMx('not-an-email') + expect(result.allowed).toBe(true) + expect(mockResolveMx).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/messaging/email/validation.server.ts b/apps/sim/lib/messaging/email/validation.server.ts new file mode 100644 index 00000000000..2d1df5b3048 --- /dev/null +++ b/apps/sim/lib/messaging/email/validation.server.ts @@ -0,0 +1,98 @@ +import type { MxRecord } from 'dns' +import dns from 'dns/promises' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' + +const logger = createLogger('EmailValidationServer') + +const MX_LOOKUP_TIMEOUT_MS = 3000 + +/** + * MX-host substrings to block, supplied at runtime via `BLOCKED_EMAIL_MX_HOSTS`. + * + * Signup-spam botnets rotate throwaway domains rapidly but funnel them through a + * small number of shared catch-all mail providers, so the resolved MX host is a + * far more stable signal than the domain itself. Each entry is matched as a + * case-insensitive substring against the domain's resolved MX exchanges. No + * hosts are hardcoded — operators configure their own denylist out of band. + */ +function getBlockedMxHosts(): string[] { + return ( + env.BLOCKED_EMAIL_MX_HOSTS?.split(',') + .map((h) => h.trim().toLowerCase()) + .filter(Boolean) ?? [] + ) +} + +export interface SignupEmailCheck { + /** Whether the email may proceed to signup. */ + allowed: boolean + /** Machine-readable block reason, present only when `allowed` is false. */ + reason?: 'no_mx' | 'blocked_mx_backend' +} + +/** + * Server-side signup email validation backed by an MX lookup. + * + * Rejects domains that resolve to no mail server (`no_mx`) or to a denylisted + * catch-all backend (`blocked_mx_backend`). Designed to be fail-open: any DNS + * timeout or transient resolver error allows the signup through so legitimate + * users are never blocked by an infrastructure blip. Only a definitive + * "domain has no MX" answer (`ENOTFOUND` / `ENODATA`) blocks. + * + * Server-only — imports `dns/promises`. Never import from client code. Gated by the caller + * behind `isSignupMxValidationEnabled`; this function performs the check unconditionally. + */ +export async function validateSignupEmailMx(email: string): Promise { + const domain = email.split('@')[1]?.toLowerCase() + if (!domain) return { allowed: true } + + let records: MxRecord[] + let timeoutHandle: ReturnType | undefined + try { + records = await Promise.race([ + dns.resolveMx(domain), + new Promise((_, reject) => { + timeoutHandle = setTimeout( + () => reject(new Error('mx_lookup_timeout')), + MX_LOOKUP_TIMEOUT_MS + ) + }), + ]) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOTFOUND' || code === 'ENODATA') { + logger.info('Blocked signup: domain has no MX record', { domain }) + return { allowed: false, reason: 'no_mx' } + } + logger.warn('MX lookup failed; allowing signup (fail-open)', { + domain, + error: getErrorMessage(error), + }) + return { allowed: true } + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle) + } + + if (!records || records.length === 0) { + logger.info('Blocked signup: domain has no MX record', { domain }) + return { allowed: false, reason: 'no_mx' } + } + + const blocked = getBlockedMxHosts() + const match = records.find((record) => { + const exchange = record.exchange.toLowerCase() + return blocked.some((host) => exchange.includes(host)) + }) + + if (match) { + logger.info('Blocked signup: denylisted MX backend', { + domain, + exchange: match.exchange, + }) + return { allowed: false, reason: 'blocked_mx_backend' } + } + + return { allowed: true } +}