-
Notifications
You must be signed in to change notification settings - Fork 3.6k
fix(auth): block signup spam by denylisting shared MX backends #4790
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
078c469
fix(auth): block signup spam by denylisting shared MX backends
waleedlatif1 b2c931d
refactor(auth): make MX signup validation opt-in (SIGNUP_MX_VALIDATIO…
waleedlatif1 725f380
fix(auth): clear MX-lookup timeout to avoid dangling timer on success
waleedlatif1 457dea6
refactor(auth): remove hardcoded MX denylist defaults
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SignupEmailCheck> { | ||
| const domain = email.split('@')[1]?.toLowerCase() | ||
| if (!domain) return { allowed: true } | ||
|
|
||
| let records: MxRecord[] | ||
| let timeoutHandle: ReturnType<typeof setTimeout> | undefined | ||
| try { | ||
| records = await Promise.race([ | ||
| dns.resolveMx(domain), | ||
| new Promise<never>((_, reject) => { | ||
| timeoutHandle = setTimeout( | ||
| () => reject(new Error('mx_lookup_timeout')), | ||
| MX_LOOKUP_TIMEOUT_MS | ||
| ) | ||
| }), | ||
| ]) | ||
| } catch (error) { | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
| 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 } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.