From 078c469fa218f1dd29927a818648c46869920972 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 29 May 2026 10:58:14 -0700 Subject: [PATCH 1/4] fix(auth): block signup spam by denylisting shared MX backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signup-spam bots rotate throwaway domains rapidly but funnel them through a small number of shared catch-all mail providers. Across the current wave, 85% of bot domains resolved to just two MX backends (smtp.215.im, email.gravityengine.cc), while every domain differed — so the resolved MX host is a far more durable signal than the domain itself. Add a server-only MX validator (validateSignupEmailMx) that resolves the domain's MX records during /sign-up/email and rejects: - domains with no MX record (no_mx) - domains whose MX backend is on the denylist (blocked_mx_backend) Seeded with the two observed backends; extend at runtime via BLOCKED_EMAIL_MX_HOSTS. Fail-open on DNS timeout/transient error so legitimate users are never blocked by a resolver blip; kill switch via DISABLE_SIGNUP_MX_VALIDATION. Returns a clean 403 (APIError), not a 500. --- apps/sim/lib/auth/auth.ts | 10 ++ apps/sim/lib/core/config/env.ts | 2 + .../messaging/email/validation.server.test.ts | 99 +++++++++++++++++++ .../lib/messaging/email/validation.server.ts | 93 +++++++++++++++++ 4 files changed, 204 insertions(+) create mode 100644 apps/sim/lib/messaging/email/validation.server.test.ts create mode 100644 apps/sim/lib/messaging/email/validation.server.ts diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index faffc248668..5e4d86cc346 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -85,6 +85,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 +844,15 @@ export const auth = betterAuth({ }) } + if (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..a2477d69e57 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") + BLOCKED_EMAIL_MX_HOSTS: z.string().optional(), // Comma-separated MX-host substrings blocked from signing up; matches the domain's resolved MX backend (e.g., "215.im,gravityengine.cc"). Catches throwaway domains that share a mail backend. Merged with built-in defaults. + DISABLE_SIGNUP_MX_VALIDATION: z.boolean().optional(), // Kill switch to disable MX-based signup validation without a deploy 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/messaging/email/validation.server.test.ts b/apps/sim/lib/messaging/email/validation.server.test.ts new file mode 100644 index 00000000000..cd59991e9e4 --- /dev/null +++ b/apps/sim/lib/messaging/email/validation.server.test.ts @@ -0,0 +1,99 @@ +/** + * @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, + DISABLE_SIGNUP_MX_VALIDATION: false, + }, +})) + +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 + envRef.DISABLE_SIGNUP_MX_VALIDATION = false + }) + + it('blocks the known shared spam backend 215.im', async () => { + mockResolveMx.mockResolvedValue(mx('smtp.215.im')) + const result = await validateSignupEmailMx('simuser_abc@lyi25swr.cn') + expect(result.allowed).toBe(false) + expect(result.reason).toBe('blocked_mx_backend') + }) + + it('blocks gravityengine.cc backend', async () => { + mockResolveMx.mockResolvedValue(mx('email.gravityengine.cc')) + const result = await validateSignupEmailMx('x@acgfun.eu.org') + expect(result.allowed).toBe(false) + expect(result.reason).toBe('blocked_mx_backend') + }) + + 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('honors additional backends from BLOCKED_EMAIL_MX_HOSTS', async () => { + envRef.BLOCKED_EMAIL_MX_HOSTS = 'newbadhost.example' + mockResolveMx.mockResolvedValue(mx('mx1.newbadhost.example')) + const result = await validateSignupEmailMx('x@rotated-domain.top') + expect(result.allowed).toBe(false) + expect(result.reason).toBe('blocked_mx_backend') + }) + + it('respects the DISABLE_SIGNUP_MX_VALIDATION kill switch', async () => { + envRef.DISABLE_SIGNUP_MX_VALIDATION = true + mockResolveMx.mockResolvedValue(mx('smtp.215.im')) + const result = await validateSignupEmailMx('simuser_abc@lyi25swr.cn') + expect(result.allowed).toBe(true) + expect(mockResolveMx).not.toHaveBeenCalled() + }) + + 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..9862b1e9cf5 --- /dev/null +++ b/apps/sim/lib/messaging/email/validation.server.ts @@ -0,0 +1,93 @@ +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') + +/** + * Mail backends abused by signup-spam botnets. The bots 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. Matched as a case-insensitive substring against each MX + * exchange. Extend at runtime via `BLOCKED_EMAIL_MX_HOSTS`. + */ +const DEFAULT_BLOCKED_MX_HOSTS = ['215.im', 'gravityengine.cc'] as const + +const MX_LOOKUP_TIMEOUT_MS = 3000 + +function getBlockedMxHosts(): string[] { + const extra = + env.BLOCKED_EMAIL_MX_HOSTS?.split(',') + .map((h) => h.trim().toLowerCase()) + .filter(Boolean) ?? [] + return [...DEFAULT_BLOCKED_MX_HOSTS, ...extra] +} + +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. + */ +export async function validateSignupEmailMx(email: string): Promise { + if (env.DISABLE_SIGNUP_MX_VALIDATION) return { allowed: true } + + const domain = email.split('@')[1]?.toLowerCase() + if (!domain) return { allowed: true } + + let records: MxRecord[] + try { + records = await Promise.race([ + dns.resolveMx(domain), + new Promise((_, reject) => + 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 } + } + + 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 } +} From b2c931d87caee9bf9b20d2efa96a648946badd9f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 29 May 2026 11:04:54 -0700 Subject: [PATCH 2/4] refactor(auth): make MX signup validation opt-in (SIGNUP_MX_VALIDATION_ENABLED) Aligns with the sibling feature SIGNUP_EMAIL_VALIDATION_ENABLED (disposable blocking via harmony), which is also opt-in. Default-off avoids adding a DNS dependency to the signup path and prevents surprise signup blocking on self-hosted deployments with non-standard mail setups (internal domains, or a too-broad MX entry catching legit shared infra like Cloudflare Email Routing). Enable on hosted/abuse-targeted deployments via SIGNUP_MX_VALIDATION_ENABLED; the flag doubles as the kill switch, so the separate DISABLE_ flag is removed. --- apps/sim/lib/auth/auth.ts | 3 ++- apps/sim/lib/core/config/env.ts | 4 ++-- apps/sim/lib/core/config/feature-flags.ts | 7 +++++++ apps/sim/lib/messaging/email/validation.server.test.ts | 10 ---------- apps/sim/lib/messaging/email/validation.server.ts | 5 ++--- 5 files changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 5e4d86cc346..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' @@ -844,7 +845,7 @@ export const auth = betterAuth({ }) } - if (ctx.path.startsWith('/sign-up/email') && ctx.body?.email) { + 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', { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index a2477d69e57..b07429ac74b 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -27,8 +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") - BLOCKED_EMAIL_MX_HOSTS: z.string().optional(), // Comma-separated MX-host substrings blocked from signing up; matches the domain's resolved MX backend (e.g., "215.im,gravityengine.cc"). Catches throwaway domains that share a mail backend. Merged with built-in defaults. - DISABLE_SIGNUP_MX_VALIDATION: z.boolean().optional(), // Kill switch to disable MX-based signup validation without a deploy + 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; matches the domain's resolved MX backend (e.g., "215.im,gravityengine.cc"). Catches throwaway domains that share a mail backend. Merged with built-in defaults. 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 index cd59991e9e4..eb3b882d054 100644 --- a/apps/sim/lib/messaging/email/validation.server.test.ts +++ b/apps/sim/lib/messaging/email/validation.server.test.ts @@ -7,7 +7,6 @@ const { mockResolveMx, envRef } = vi.hoisted(() => ({ mockResolveMx: vi.fn(), envRef: { BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined, - DISABLE_SIGNUP_MX_VALIDATION: false, }, })) @@ -30,7 +29,6 @@ describe('validateSignupEmailMx', () => { beforeEach(() => { vi.clearAllMocks() envRef.BLOCKED_EMAIL_MX_HOSTS = undefined - envRef.DISABLE_SIGNUP_MX_VALIDATION = false }) it('blocks the known shared spam backend 215.im', async () => { @@ -83,14 +81,6 @@ describe('validateSignupEmailMx', () => { expect(result.reason).toBe('blocked_mx_backend') }) - it('respects the DISABLE_SIGNUP_MX_VALIDATION kill switch', async () => { - envRef.DISABLE_SIGNUP_MX_VALIDATION = true - mockResolveMx.mockResolvedValue(mx('smtp.215.im')) - const result = await validateSignupEmailMx('simuser_abc@lyi25swr.cn') - expect(result.allowed).toBe(true) - expect(mockResolveMx).not.toHaveBeenCalled() - }) - 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) diff --git a/apps/sim/lib/messaging/email/validation.server.ts b/apps/sim/lib/messaging/email/validation.server.ts index 9862b1e9cf5..bcd585b95e5 100644 --- a/apps/sim/lib/messaging/email/validation.server.ts +++ b/apps/sim/lib/messaging/email/validation.server.ts @@ -41,11 +41,10 @@ export interface SignupEmailCheck { * 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. + * 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 { - if (env.DISABLE_SIGNUP_MX_VALIDATION) return { allowed: true } - const domain = email.split('@')[1]?.toLowerCase() if (!domain) return { allowed: true } From 725f380b447e3d613baa8f982eef384a0ad91afd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 29 May 2026 11:11:12 -0700 Subject: [PATCH 3/4] fix(auth): clear MX-lookup timeout to avoid dangling timer on success --- apps/sim/lib/messaging/email/validation.server.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/messaging/email/validation.server.ts b/apps/sim/lib/messaging/email/validation.server.ts index bcd585b95e5..95304049207 100644 --- a/apps/sim/lib/messaging/email/validation.server.ts +++ b/apps/sim/lib/messaging/email/validation.server.ts @@ -49,12 +49,16 @@ export async function validateSignupEmailMx(email: string): Promise | undefined try { records = await Promise.race([ dns.resolveMx(domain), - new Promise((_, reject) => - setTimeout(() => reject(new Error('mx_lookup_timeout')), MX_LOOKUP_TIMEOUT_MS) - ), + new Promise((_, reject) => { + timeoutHandle = setTimeout( + () => reject(new Error('mx_lookup_timeout')), + MX_LOOKUP_TIMEOUT_MS + ) + }), ]) } catch (error) { const code = (error as NodeJS.ErrnoException).code @@ -67,6 +71,8 @@ export async function validateSignupEmailMx(email: string): Promise Date: Fri, 29 May 2026 11:31:52 -0700 Subject: [PATCH 4/4] refactor(auth): remove hardcoded MX denylist defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MX-backend denylist is now entirely operator-supplied via BLOCKED_EMAIL_MX_HOSTS. Sim is open source, so no specific mail backends are named in the repo, the env example, or the tests — deployments configure their own list out of band (e.g. via secrets). The no-MX hygiene check is unchanged; with an empty denylist no backend is blocked. --- apps/sim/lib/core/config/env.ts | 2 +- .../messaging/email/validation.server.test.ts | 29 ++++++++++--------- .../lib/messaging/email/validation.server.ts | 22 +++++++------- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index b07429ac74b..3b871859295 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -28,7 +28,7 @@ export const env = createEnv({ 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; matches the domain's resolved MX backend (e.g., "215.im,gravityengine.cc"). Catches throwaway domains that share a mail backend. Merged with built-in defaults. Only used when SIGNUP_MX_VALIDATION_ENABLED is set. + 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/messaging/email/validation.server.test.ts b/apps/sim/lib/messaging/email/validation.server.test.ts index eb3b882d054..9fcfb4de6d4 100644 --- a/apps/sim/lib/messaging/email/validation.server.test.ts +++ b/apps/sim/lib/messaging/email/validation.server.test.ts @@ -31,20 +31,29 @@ describe('validateSignupEmailMx', () => { envRef.BLOCKED_EMAIL_MX_HOSTS = undefined }) - it('blocks the known shared spam backend 215.im', async () => { - mockResolveMx.mockResolvedValue(mx('smtp.215.im')) - const result = await validateSignupEmailMx('simuser_abc@lyi25swr.cn') + 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('blocks gravityengine.cc backend', async () => { - mockResolveMx.mockResolvedValue(mx('email.gravityengine.cc')) - const result = await validateSignupEmailMx('x@acgfun.eu.org') + 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') @@ -73,14 +82,6 @@ describe('validateSignupEmailMx', () => { expect(result.allowed).toBe(true) }) - it('honors additional backends from BLOCKED_EMAIL_MX_HOSTS', async () => { - envRef.BLOCKED_EMAIL_MX_HOSTS = 'newbadhost.example' - mockResolveMx.mockResolvedValue(mx('mx1.newbadhost.example')) - const result = await validateSignupEmailMx('x@rotated-domain.top') - expect(result.allowed).toBe(false) - expect(result.reason).toBe('blocked_mx_backend') - }) - 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) diff --git a/apps/sim/lib/messaging/email/validation.server.ts b/apps/sim/lib/messaging/email/validation.server.ts index 95304049207..2d1df5b3048 100644 --- a/apps/sim/lib/messaging/email/validation.server.ts +++ b/apps/sim/lib/messaging/email/validation.server.ts @@ -6,23 +6,23 @@ import { env } from '@/lib/core/config/env' const logger = createLogger('EmailValidationServer') -/** - * Mail backends abused by signup-spam botnets. The bots 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. Matched as a case-insensitive substring against each MX - * exchange. Extend at runtime via `BLOCKED_EMAIL_MX_HOSTS`. - */ -const DEFAULT_BLOCKED_MX_HOSTS = ['215.im', 'gravityengine.cc'] as const - 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[] { - const extra = + return ( env.BLOCKED_EMAIL_MX_HOSTS?.split(',') .map((h) => h.trim().toLowerCase()) .filter(Boolean) ?? [] - return [...DEFAULT_BLOCKED_MX_HOSTS, ...extra] + ) } export interface SignupEmailCheck {