Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,15 @@ import {
isOrganizationsEnabled,
isRegistrationDisabled,
isSignupEmailValidationEnabled,
isSignupMxValidationEnabled,
} from '@/lib/core/config/feature-flags'
import { PlatformEvents } from '@/lib/core/telemetry'
import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls'
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server'
import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle'
import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
Expand Down Expand Up @@ -843,6 +845,15 @@ export const auth = betterAuth({
})
}

if (isSignupMxValidationEnabled && ctx.path.startsWith('/sign-up/email') && ctx.body?.email) {
const mxCheck = await validateSignupEmailMx(ctx.body.email)
if (!mxCheck.allowed) {
throw new APIError('FORBIDDEN', {
message: 'Sign-ups from this email domain are not allowed.',
})
}
}

if (ctx.path === '/oauth2/authorize' || ctx.path === '/oauth2/token') {
const clientId = (ctx.query?.client_id ?? ctx.body?.client_id) as string | undefined
if (clientId && isMetadataUrl(clientId)) {
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const env = createEnv({
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com")
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.
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.
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.
TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification
SIGNUP_EMAIL_VALIDATION_ENABLED: z.boolean().optional(), // Enable disposable email blocking via better-auth-harmony (55K+ domains)
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/lib/core/config/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ export const isEmailPasswordEnabled = !isFalsy(env.EMAIL_PASSWORD_SIGNUP_ENABLED
*/
export const isSignupEmailValidationEnabled = isTruthy(env.SIGNUP_EMAIL_VALIDATION_ENABLED)

/**
* Is MX-based signup validation enabled (blocks no-MX domains and denylisted shared spam
* mail backends). Opt-in to avoid adding a DNS dependency or blocking legitimate signups on
* self-hosted deployments with non-standard mail setups; enable on abuse-targeted deployments.
*/
export const isSignupMxValidationEnabled = isTruthy(env.SIGNUP_MX_VALIDATION_ENABLED)

/**
* Is Trigger.dev enabled for async job processing
*/
Expand Down
90 changes: 90 additions & 0 deletions apps/sim/lib/messaging/email/validation.server.test.ts
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()
})
})
98 changes: 98 additions & 0 deletions apps/sim/lib/messaging/email/validation.server.ts
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'
Comment thread
waleedlatif1 marked this conversation as resolved.

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) {
Comment thread
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 }
}
Loading