d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
88 lines
3.0 KiB
TypeScript
88 lines
3.0 KiB
TypeScript
import type { MxRecord } from 'dns'
|
|
import dns from 'dns/promises'
|
|
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
|
|
const logger = createLogger('EmailValidationServer')
|
|
|
|
const MX_LOOKUP_TIMEOUT_MS = 3000
|
|
|
|
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.
|
|
*
|
|
* `blockedMxHosts` are case-insensitive substrings matched against each resolved
|
|
* MX exchange — signup-spam botnets rotate throwaway domains but funnel them
|
|
* through a few shared catch-all backends, so the MX host is a more stable signal
|
|
* than the domain. Sourced from access-control config (AppConfig or env fallback).
|
|
*
|
|
* 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,
|
|
blockedMxHosts: 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) {
|
|
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 match = records.find((record) => {
|
|
const exchange = record.exchange.toLowerCase()
|
|
return blockedMxHosts.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 }
|
|
}
|