chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user