Skip to content

Commit dc6073e

Browse files
authored
fix(auth): block signup spam by denylisting shared MX backends (#4790)
* fix(auth): block signup spam by denylisting shared MX backends 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. * 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. * fix(auth): clear MX-lookup timeout to avoid dangling timer on success * refactor(auth): remove hardcoded MX denylist defaults 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.
1 parent 6598927 commit dc6073e

5 files changed

Lines changed: 208 additions & 0 deletions

File tree

apps/sim/lib/auth/auth.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,15 @@ import {
7878
isOrganizationsEnabled,
7979
isRegistrationDisabled,
8080
isSignupEmailValidationEnabled,
81+
isSignupMxValidationEnabled,
8182
} from '@/lib/core/config/feature-flags'
8283
import { PlatformEvents } from '@/lib/core/telemetry'
8384
import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls'
8485
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
8586
import { sendEmail } from '@/lib/messaging/email/mailer'
8687
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
8788
import { quickValidateEmail } from '@/lib/messaging/email/validation'
89+
import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server'
8890
import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle'
8991
import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server'
9092
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
@@ -843,6 +845,15 @@ export const auth = betterAuth({
843845
})
844846
}
845847

848+
if (isSignupMxValidationEnabled && ctx.path.startsWith('/sign-up/email') && ctx.body?.email) {
849+
const mxCheck = await validateSignupEmailMx(ctx.body.email)
850+
if (!mxCheck.allowed) {
851+
throw new APIError('FORBIDDEN', {
852+
message: 'Sign-ups from this email domain are not allowed.',
853+
})
854+
}
855+
}
856+
846857
if (ctx.path === '/oauth2/authorize' || ctx.path === '/oauth2/token') {
847858
const clientId = (ctx.query?.client_id ?? ctx.body?.client_id) as string | undefined
848859
if (clientId && isMetadataUrl(clientId)) {

apps/sim/lib/core/config/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export const env = createEnv({
2727
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
2828
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
2929
BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com")
30+
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.
31+
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.
3032
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.
3133
TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification
3234
SIGNUP_EMAIL_VALIDATION_ENABLED: z.boolean().optional(), // Enable disposable email blocking via better-auth-harmony (55K+ domains)

apps/sim/lib/core/config/feature-flags.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ export const isEmailPasswordEnabled = !isFalsy(env.EMAIL_PASSWORD_SIGNUP_ENABLED
8181
*/
8282
export const isSignupEmailValidationEnabled = isTruthy(env.SIGNUP_EMAIL_VALIDATION_ENABLED)
8383

84+
/**
85+
* Is MX-based signup validation enabled (blocks no-MX domains and denylisted shared spam
86+
* mail backends). Opt-in to avoid adding a DNS dependency or blocking legitimate signups on
87+
* self-hosted deployments with non-standard mail setups; enable on abuse-targeted deployments.
88+
*/
89+
export const isSignupMxValidationEnabled = isTruthy(env.SIGNUP_MX_VALIDATION_ENABLED)
90+
8491
/**
8592
* Is Trigger.dev enabled for async job processing
8693
*/
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockResolveMx, envRef } = vi.hoisted(() => ({
7+
mockResolveMx: vi.fn(),
8+
envRef: {
9+
BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined,
10+
},
11+
}))
12+
13+
vi.mock('dns/promises', () => ({
14+
default: { resolveMx: mockResolveMx },
15+
}))
16+
17+
vi.mock('@/lib/core/config/env', () => ({
18+
get env() {
19+
return envRef
20+
},
21+
}))
22+
23+
import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server'
24+
25+
const mx = (...hosts: string[]) =>
26+
hosts.map((exchange, i) => ({ exchange, priority: (i + 1) * 10 }))
27+
28+
describe('validateSignupEmailMx', () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks()
31+
envRef.BLOCKED_EMAIL_MX_HOSTS = undefined
32+
})
33+
34+
it('blocks a domain whose MX backend is on the configured denylist', async () => {
35+
envRef.BLOCKED_EMAIL_MX_HOSTS = 'blocked-backend.example'
36+
mockResolveMx.mockResolvedValue(mx('smtp.blocked-backend.example'))
37+
const result = await validateSignupEmailMx('user@rotated-domain.test')
38+
expect(result.allowed).toBe(false)
39+
expect(result.reason).toBe('blocked_mx_backend')
40+
})
41+
42+
it('matches the denylist as a case-insensitive substring of the MX exchange', async () => {
43+
envRef.BLOCKED_EMAIL_MX_HOSTS = 'Blocked-Backend.Example'
44+
mockResolveMx.mockResolvedValue(mx('mx1.blocked-backend.example'))
45+
const result = await validateSignupEmailMx('user@another-domain.test')
46+
expect(result.allowed).toBe(false)
47+
expect(result.reason).toBe('blocked_mx_backend')
48+
})
49+
50+
it('does not block any backend when the denylist is empty (no hardcoded defaults)', async () => {
51+
envRef.BLOCKED_EMAIL_MX_HOSTS = undefined
52+
mockResolveMx.mockResolvedValue(mx('smtp.blocked-backend.example'))
53+
const result = await validateSignupEmailMx('user@rotated-domain.test')
54+
expect(result.allowed).toBe(true)
55+
})
56+
57+
it('allows a legitimate domain (gmail)', async () => {
58+
mockResolveMx.mockResolvedValue(
59+
mx('gmail-smtp-in.l.google.com', 'alt1.gmail-smtp-in.l.google.com')
60+
)
61+
const result = await validateSignupEmailMx('real.person@gmail.com')
62+
expect(result.allowed).toBe(true)
63+
})
64+
65+
it('blocks a domain with no MX records (ENOTFOUND)', async () => {
66+
mockResolveMx.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOTFOUND' }))
67+
const result = await validateSignupEmailMx('x@no-such-domain.invalid')
68+
expect(result.allowed).toBe(false)
69+
expect(result.reason).toBe('no_mx')
70+
})
71+
72+
it('blocks a domain that resolves to an empty MX set', async () => {
73+
mockResolveMx.mockResolvedValue([])
74+
const result = await validateSignupEmailMx('x@empty.example')
75+
expect(result.allowed).toBe(false)
76+
expect(result.reason).toBe('no_mx')
77+
})
78+
79+
it('fails open on a transient DNS error (does not block legit users)', async () => {
80+
mockResolveMx.mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' }))
81+
const result = await validateSignupEmailMx('user@some-real-domain.com')
82+
expect(result.allowed).toBe(true)
83+
})
84+
85+
it('allows when the email has no domain (defers to other validation)', async () => {
86+
const result = await validateSignupEmailMx('not-an-email')
87+
expect(result.allowed).toBe(true)
88+
expect(mockResolveMx).not.toHaveBeenCalled()
89+
})
90+
})
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { MxRecord } from 'dns'
2+
import dns from 'dns/promises'
3+
import { createLogger } from '@sim/logger'
4+
import { getErrorMessage } from '@sim/utils/errors'
5+
import { env } from '@/lib/core/config/env'
6+
7+
const logger = createLogger('EmailValidationServer')
8+
9+
const MX_LOOKUP_TIMEOUT_MS = 3000
10+
11+
/**
12+
* MX-host substrings to block, supplied at runtime via `BLOCKED_EMAIL_MX_HOSTS`.
13+
*
14+
* Signup-spam botnets rotate throwaway domains rapidly but funnel them through a
15+
* small number of shared catch-all mail providers, so the resolved MX host is a
16+
* far more stable signal than the domain itself. Each entry is matched as a
17+
* case-insensitive substring against the domain's resolved MX exchanges. No
18+
* hosts are hardcoded — operators configure their own denylist out of band.
19+
*/
20+
function getBlockedMxHosts(): string[] {
21+
return (
22+
env.BLOCKED_EMAIL_MX_HOSTS?.split(',')
23+
.map((h) => h.trim().toLowerCase())
24+
.filter(Boolean) ?? []
25+
)
26+
}
27+
28+
export interface SignupEmailCheck {
29+
/** Whether the email may proceed to signup. */
30+
allowed: boolean
31+
/** Machine-readable block reason, present only when `allowed` is false. */
32+
reason?: 'no_mx' | 'blocked_mx_backend'
33+
}
34+
35+
/**
36+
* Server-side signup email validation backed by an MX lookup.
37+
*
38+
* Rejects domains that resolve to no mail server (`no_mx`) or to a denylisted
39+
* catch-all backend (`blocked_mx_backend`). Designed to be fail-open: any DNS
40+
* timeout or transient resolver error allows the signup through so legitimate
41+
* users are never blocked by an infrastructure blip. Only a definitive
42+
* "domain has no MX" answer (`ENOTFOUND` / `ENODATA`) blocks.
43+
*
44+
* Server-only — imports `dns/promises`. Never import from client code. Gated by the caller
45+
* behind `isSignupMxValidationEnabled`; this function performs the check unconditionally.
46+
*/
47+
export async function validateSignupEmailMx(email: string): Promise<SignupEmailCheck> {
48+
const domain = email.split('@')[1]?.toLowerCase()
49+
if (!domain) return { allowed: true }
50+
51+
let records: MxRecord[]
52+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
53+
try {
54+
records = await Promise.race([
55+
dns.resolveMx(domain),
56+
new Promise<never>((_, reject) => {
57+
timeoutHandle = setTimeout(
58+
() => reject(new Error('mx_lookup_timeout')),
59+
MX_LOOKUP_TIMEOUT_MS
60+
)
61+
}),
62+
])
63+
} catch (error) {
64+
const code = (error as NodeJS.ErrnoException).code
65+
if (code === 'ENOTFOUND' || code === 'ENODATA') {
66+
logger.info('Blocked signup: domain has no MX record', { domain })
67+
return { allowed: false, reason: 'no_mx' }
68+
}
69+
logger.warn('MX lookup failed; allowing signup (fail-open)', {
70+
domain,
71+
error: getErrorMessage(error),
72+
})
73+
return { allowed: true }
74+
} finally {
75+
if (timeoutHandle) clearTimeout(timeoutHandle)
76+
}
77+
78+
if (!records || records.length === 0) {
79+
logger.info('Blocked signup: domain has no MX record', { domain })
80+
return { allowed: false, reason: 'no_mx' }
81+
}
82+
83+
const blocked = getBlockedMxHosts()
84+
const match = records.find((record) => {
85+
const exchange = record.exchange.toLowerCase()
86+
return blocked.some((host) => exchange.includes(host))
87+
})
88+
89+
if (match) {
90+
logger.info('Blocked signup: denylisted MX backend', {
91+
domain,
92+
exchange: match.exchange,
93+
})
94+
return { allowed: false, reason: 'blocked_mx_backend' }
95+
}
96+
97+
return { allowed: true }
98+
}

0 commit comments

Comments
 (0)