Skip to content

Commit 078c469

Browse files
committed
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.
1 parent 066cd70 commit 078c469

4 files changed

Lines changed: 204 additions & 0 deletions

File tree

apps/sim/lib/auth/auth.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import { processCredentialDraft } from '@/lib/credentials/draft-processor'
8585
import { sendEmail } from '@/lib/messaging/email/mailer'
8686
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
8787
import { quickValidateEmail } from '@/lib/messaging/email/validation'
88+
import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server'
8889
import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle'
8990
import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server'
9091
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
@@ -843,6 +844,15 @@ export const auth = betterAuth({
843844
})
844845
}
845846

847+
if (ctx.path.startsWith('/sign-up/email') && ctx.body?.email) {
848+
const mxCheck = await validateSignupEmailMx(ctx.body.email)
849+
if (!mxCheck.allowed) {
850+
throw new APIError('FORBIDDEN', {
851+
message: 'Sign-ups from this email domain are not allowed.',
852+
})
853+
}
854+
}
855+
846856
if (ctx.path === '/oauth2/authorize' || ctx.path === '/oauth2/token') {
847857
const clientId = (ctx.query?.client_id ?? ctx.body?.client_id) as string | undefined
848858
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+
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.
31+
DISABLE_SIGNUP_MX_VALIDATION: z.boolean().optional(), // Kill switch to disable MX-based signup validation without a deploy
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)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
DISABLE_SIGNUP_MX_VALIDATION: false,
11+
},
12+
}))
13+
14+
vi.mock('dns/promises', () => ({
15+
default: { resolveMx: mockResolveMx },
16+
}))
17+
18+
vi.mock('@/lib/core/config/env', () => ({
19+
get env() {
20+
return envRef
21+
},
22+
}))
23+
24+
import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server'
25+
26+
const mx = (...hosts: string[]) =>
27+
hosts.map((exchange, i) => ({ exchange, priority: (i + 1) * 10 }))
28+
29+
describe('validateSignupEmailMx', () => {
30+
beforeEach(() => {
31+
vi.clearAllMocks()
32+
envRef.BLOCKED_EMAIL_MX_HOSTS = undefined
33+
envRef.DISABLE_SIGNUP_MX_VALIDATION = false
34+
})
35+
36+
it('blocks the known shared spam backend 215.im', async () => {
37+
mockResolveMx.mockResolvedValue(mx('smtp.215.im'))
38+
const result = await validateSignupEmailMx('simuser_abc@lyi25swr.cn')
39+
expect(result.allowed).toBe(false)
40+
expect(result.reason).toBe('blocked_mx_backend')
41+
})
42+
43+
it('blocks gravityengine.cc backend', async () => {
44+
mockResolveMx.mockResolvedValue(mx('email.gravityengine.cc'))
45+
const result = await validateSignupEmailMx('x@acgfun.eu.org')
46+
expect(result.allowed).toBe(false)
47+
expect(result.reason).toBe('blocked_mx_backend')
48+
})
49+
50+
it('allows a legitimate domain (gmail)', async () => {
51+
mockResolveMx.mockResolvedValue(
52+
mx('gmail-smtp-in.l.google.com', 'alt1.gmail-smtp-in.l.google.com')
53+
)
54+
const result = await validateSignupEmailMx('real.person@gmail.com')
55+
expect(result.allowed).toBe(true)
56+
})
57+
58+
it('blocks a domain with no MX records (ENOTFOUND)', async () => {
59+
mockResolveMx.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOTFOUND' }))
60+
const result = await validateSignupEmailMx('x@no-such-domain.invalid')
61+
expect(result.allowed).toBe(false)
62+
expect(result.reason).toBe('no_mx')
63+
})
64+
65+
it('blocks a domain that resolves to an empty MX set', async () => {
66+
mockResolveMx.mockResolvedValue([])
67+
const result = await validateSignupEmailMx('x@empty.example')
68+
expect(result.allowed).toBe(false)
69+
expect(result.reason).toBe('no_mx')
70+
})
71+
72+
it('fails open on a transient DNS error (does not block legit users)', async () => {
73+
mockResolveMx.mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' }))
74+
const result = await validateSignupEmailMx('user@some-real-domain.com')
75+
expect(result.allowed).toBe(true)
76+
})
77+
78+
it('honors additional backends from BLOCKED_EMAIL_MX_HOSTS', async () => {
79+
envRef.BLOCKED_EMAIL_MX_HOSTS = 'newbadhost.example'
80+
mockResolveMx.mockResolvedValue(mx('mx1.newbadhost.example'))
81+
const result = await validateSignupEmailMx('x@rotated-domain.top')
82+
expect(result.allowed).toBe(false)
83+
expect(result.reason).toBe('blocked_mx_backend')
84+
})
85+
86+
it('respects the DISABLE_SIGNUP_MX_VALIDATION kill switch', async () => {
87+
envRef.DISABLE_SIGNUP_MX_VALIDATION = true
88+
mockResolveMx.mockResolvedValue(mx('smtp.215.im'))
89+
const result = await validateSignupEmailMx('simuser_abc@lyi25swr.cn')
90+
expect(result.allowed).toBe(true)
91+
expect(mockResolveMx).not.toHaveBeenCalled()
92+
})
93+
94+
it('allows when the email has no domain (defers to other validation)', async () => {
95+
const result = await validateSignupEmailMx('not-an-email')
96+
expect(result.allowed).toBe(true)
97+
expect(mockResolveMx).not.toHaveBeenCalled()
98+
})
99+
})
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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+
/**
10+
* Mail backends abused by signup-spam botnets. The bots rotate throwaway
11+
* domains rapidly but funnel them through a small number of shared catch-all
12+
* mail providers, so the resolved MX host is a far more stable signal than the
13+
* domain itself. Matched as a case-insensitive substring against each MX
14+
* exchange. Extend at runtime via `BLOCKED_EMAIL_MX_HOSTS`.
15+
*/
16+
const DEFAULT_BLOCKED_MX_HOSTS = ['215.im', 'gravityengine.cc'] as const
17+
18+
const MX_LOOKUP_TIMEOUT_MS = 3000
19+
20+
function getBlockedMxHosts(): string[] {
21+
const extra =
22+
env.BLOCKED_EMAIL_MX_HOSTS?.split(',')
23+
.map((h) => h.trim().toLowerCase())
24+
.filter(Boolean) ?? []
25+
return [...DEFAULT_BLOCKED_MX_HOSTS, ...extra]
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.
45+
*/
46+
export async function validateSignupEmailMx(email: string): Promise<SignupEmailCheck> {
47+
if (env.DISABLE_SIGNUP_MX_VALIDATION) return { allowed: true }
48+
49+
const domain = email.split('@')[1]?.toLowerCase()
50+
if (!domain) return { allowed: true }
51+
52+
let records: MxRecord[]
53+
try {
54+
records = await Promise.race([
55+
dns.resolveMx(domain),
56+
new Promise<never>((_, reject) =>
57+
setTimeout(() => reject(new Error('mx_lookup_timeout')), MX_LOOKUP_TIMEOUT_MS)
58+
),
59+
])
60+
} catch (error) {
61+
const code = (error as NodeJS.ErrnoException).code
62+
if (code === 'ENOTFOUND' || code === 'ENODATA') {
63+
logger.info('Blocked signup: domain has no MX record', { domain })
64+
return { allowed: false, reason: 'no_mx' }
65+
}
66+
logger.warn('MX lookup failed; allowing signup (fail-open)', {
67+
domain,
68+
error: getErrorMessage(error),
69+
})
70+
return { allowed: true }
71+
}
72+
73+
if (!records || records.length === 0) {
74+
logger.info('Blocked signup: domain has no MX record', { domain })
75+
return { allowed: false, reason: 'no_mx' }
76+
}
77+
78+
const blocked = getBlockedMxHosts()
79+
const match = records.find((record) => {
80+
const exchange = record.exchange.toLowerCase()
81+
return blocked.some((host) => exchange.includes(host))
82+
})
83+
84+
if (match) {
85+
logger.info('Blocked signup: denylisted MX backend', {
86+
domain,
87+
exchange: match.exchange,
88+
})
89+
return { allowed: false, reason: 'blocked_mx_backend' }
90+
}
91+
92+
return { allowed: true }
93+
}

0 commit comments

Comments
 (0)