feat(auth): dynamic signup/login ban lists via AWS AppConfig (#4911)

* feat(auth): dynamic signup/login ban lists via AWS AppConfig

- Move blocked-domain/allowlist/MX gating from env vars into AWS AppConfig (queried at runtime via the AppConfig Data SDK with a 30s in-process cache); env vars remain a fallback for self-hosted/OSS.
- Add a new bannedEmails denylist that blocks a specific address at both sign-in and sign-up.
- Generic, profile-agnostic AppConfig client so future config (feature flags) reuses the same plumbing; AppConfig client shares the same credential resolution as the S3 client.
- Defense in depth: authenticateApiKeyFromHeader now rejects keys belonging to banned users.

* fix(auth): scope bannedEmails to signup only; harden AppConfig cache

- Remove bannedEmails sign-in check: better-auth's admin plugin already blocks banned users at sign-in (session.create.before, all providers). bannedEmails is now a signup-only denylist via user.create.before, which also closed the OAuth/email-OTP sign-in bypass the bots flagged.
- AppConfig cache: track a 'loaded' flag so an empty/unseeded profile warms the cache instead of re-polling on every request; honor NextPollIntervalInSeconds to avoid throttling; dedupe concurrent cold fetches behind one in-flight poll to avoid racing the rotating session token.

* refactor(auth): drop bannedEmails; gate AppConfig on isHosted

- Remove the bannedEmails denylist entirely (better-auth banning + blockedSignupDomains cover the cases).
- Move isAppConfigEnabled into feature-flags.ts and gate it on isHosted, so AppConfig is hosted-only; self-hosted/OSS always uses the env-var fallback and never constructs the AWS client.

* fix(appconfig): preserve session token on parse error

Narrow the token-resetting catch to only the network calls. A JSON/parse failure no longer discards the already-rotated session token (the round trip succeeded), so the next poll reuses it instead of opening a new StartConfigurationSession.
This commit is contained in:
Theodore Li
2026-06-08 20:41:42 -04:00
committed by GitHub
parent ffd87a3e8f
commit 76774795c6
15 changed files with 605 additions and 100 deletions
+12
View File
@@ -108,6 +108,18 @@ describe('authenticateApiKeyFromHeader', () => {
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
})
it('returns invalid when the key belongs to a banned user', async () => {
const record = personalKeyRecord({ userBanned: true })
dbChainMockFns.where.mockResolvedValueOnce([record])
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})
expect(result).toEqual({ success: false, error: 'Invalid API key' })
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
})
it('returns invalid when the hash lookup finds no row', async () => {
dbChainMockFns.where.mockResolvedValueOnce([])
+7 -1
View File
@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { apiKey as apiKeyTable } from '@sim/db/schema'
import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { hashApiKey } from '@/lib/api-key/crypto'
@@ -47,6 +47,7 @@ interface HashCandidate {
workspaceId: string | null
type: string
expiresAt: Date | null
userBanned: boolean | null
}
/**
@@ -82,8 +83,10 @@ export async function authenticateApiKeyFromHeader(
workspaceId: apiKeyTable.workspaceId,
type: apiKeyTable.type,
expiresAt: apiKeyTable.expiresAt,
userBanned: userTable.banned,
})
.from(apiKeyTable)
.leftJoin(userTable, eq(apiKeyTable.userId, userTable.id))
.where(eq(apiKeyTable.keyHash, keyHash))
if (rows.length === 0) return INVALID
@@ -91,6 +94,9 @@ export async function authenticateApiKeyFromHeader(
const record = rows[0]
const keyType = record.type as 'personal' | 'workspace'
// Defense in depth: banning deletes a user's keys, but reject any survivor too.
if (record.userBanned) return INVALID
if (options.userId && record.userId !== options.userId) return INVALID
if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) return INVALID
if (record.expiresAt && record.expiresAt < new Date()) return INVALID
+106
View File
@@ -0,0 +1,106 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AccessControlConfig } from '@/lib/auth/access-control'
const { mockFetch, envRef, flagRef } = vi.hoisted(() => ({
mockFetch: vi.fn(),
envRef: {
APPCONFIG_APPLICATION: 'sim-staging' as string | undefined,
APPCONFIG_ENVIRONMENT: 'staging' as string | undefined,
BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined,
ALLOWED_LOGIN_EMAILS: undefined as string | undefined,
ALLOWED_LOGIN_DOMAINS: undefined as string | undefined,
BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined,
},
flagRef: { isAppConfigEnabled: false },
}))
vi.mock('@/lib/core/config/appconfig', () => ({
fetchAppConfigProfile: mockFetch,
}))
vi.mock('@/lib/core/config/env', () => ({
get env() {
return envRef
},
}))
vi.mock('@/lib/core/config/feature-flags', () => ({
get isAppConfigEnabled() {
return flagRef.isAppConfigEnabled
},
}))
import { getAccessControlConfig } from '@/lib/auth/access-control'
const empty: AccessControlConfig = {
blockedSignupDomains: [],
allowedLoginEmails: [],
allowedLoginDomains: [],
blockedEmailMxHosts: [],
}
describe('getAccessControlConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
flagRef.isAppConfigEnabled = false
Object.assign(envRef, {
BLOCKED_SIGNUP_DOMAINS: undefined,
ALLOWED_LOGIN_EMAILS: undefined,
ALLOWED_LOGIN_DOMAINS: undefined,
BLOCKED_EMAIL_MX_HOSTS: undefined,
})
})
describe('env fallback (AppConfig disabled)', () => {
it('returns empty lists when nothing is set', async () => {
expect(await getAccessControlConfig()).toEqual(empty)
expect(mockFetch).not.toHaveBeenCalled()
})
it('parses, trims, lowercases, and dedupes csv env vars', async () => {
envRef.BLOCKED_SIGNUP_DOMAINS = 'Gmail.com, yahoo.com ,gmail.com,'
envRef.ALLOWED_LOGIN_DOMAINS = 'Sim.ai'
const result = await getAccessControlConfig()
expect(result.blockedSignupDomains).toEqual(['gmail.com', 'yahoo.com'])
expect(result.allowedLoginDomains).toEqual(['sim.ai'])
expect(mockFetch).not.toHaveBeenCalled()
})
})
describe('AppConfig source (enabled)', () => {
beforeEach(() => {
flagRef.isAppConfigEnabled = true
})
it('reads the access-control profile and normalizes the payload', async () => {
mockFetch.mockImplementation((_ids, parse) =>
Promise.resolve(
parse({
blockedSignupDomains: ['X.com'],
allowedLoginDomains: ['sim.ai'],
blockedEmailMxHosts: 'not-an-array',
})
)
)
const result = await getAccessControlConfig()
expect(result.blockedSignupDomains).toEqual(['x.com'])
expect(result.allowedLoginDomains).toEqual(['sim.ai'])
expect(result.blockedEmailMxHosts).toEqual([])
expect(mockFetch).toHaveBeenCalledWith(
{ application: 'sim-staging', environment: 'staging', profile: 'access-control' },
expect.any(Function)
)
})
it('falls back to env vars when the fetch yields null', async () => {
envRef.BLOCKED_SIGNUP_DOMAINS = 'spam.example'
mockFetch.mockResolvedValue(null)
const result = await getAccessControlConfig()
expect(result.blockedSignupDomains).toEqual(['spam.example'])
})
})
})
+74
View File
@@ -0,0 +1,74 @@
import { fetchAppConfigProfile } from '@/lib/core/config/appconfig'
import { env } from '@/lib/core/config/env'
import { isAppConfigEnabled } from '@/lib/core/config/feature-flags'
/**
* Name of the AppConfig configuration profile holding the signup/login gating
* lists. This is a cross-repo contract: it must match the `CfnConfigurationProfile`
* name created by the infra stack.
*/
const ACCESS_CONTROL_PROFILE = 'access-control'
/**
* Normalized signup/login gating lists. All entries are trimmed, lowercased, and
* de-duplicated. Domains are bare hostnames; MX hosts are substrings matched
* against resolved MX exchanges; emails are full addresses.
*/
export interface AccessControlConfig {
blockedSignupDomains: string[]
allowedLoginEmails: string[]
allowedLoginDomains: string[]
blockedEmailMxHosts: string[]
}
function normalizeList(values: unknown): string[] {
if (!Array.isArray(values)) return []
return Array.from(new Set(values.map((v) => String(v).trim().toLowerCase()).filter(Boolean)))
}
function parseCsv(value: string | undefined): string[] {
return normalizeList(value?.split(','))
}
/**
* Fallback source for self-hosted/OSS/local deployments that have no AppConfig.
* Reads the same env vars the app used before AppConfig.
*/
function fromEnv(): AccessControlConfig {
return {
blockedSignupDomains: parseCsv(env.BLOCKED_SIGNUP_DOMAINS),
allowedLoginEmails: parseCsv(env.ALLOWED_LOGIN_EMAILS),
allowedLoginDomains: parseCsv(env.ALLOWED_LOGIN_DOMAINS),
blockedEmailMxHosts: parseCsv(env.BLOCKED_EMAIL_MX_HOSTS),
}
}
function parseConfig(json: unknown): AccessControlConfig {
const obj = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
return {
blockedSignupDomains: normalizeList(obj.blockedSignupDomains),
allowedLoginEmails: normalizeList(obj.allowedLoginEmails),
allowedLoginDomains: normalizeList(obj.allowedLoginDomains),
blockedEmailMxHosts: normalizeList(obj.blockedEmailMxHosts),
}
}
/**
* Resolve the current signup/login gating lists. Reads from AWS AppConfig on
* hosted deployments (cached, ~30s TTL, never blocks after the first fetch),
* otherwise falls back to env vars so self-hosted/OSS works with no AWS.
*/
export async function getAccessControlConfig(): Promise<AccessControlConfig> {
if (!isAppConfigEnabled) return fromEnv()
const value = await fetchAppConfigProfile(
{
application: env.APPCONFIG_APPLICATION as string,
environment: env.APPCONFIG_ENVIRONMENT as string,
profile: ACCESS_CONTROL_PROFILE,
},
parseConfig
)
return value ?? fromEnv()
}
+36 -47
View File
@@ -30,6 +30,7 @@ import {
renderPasswordResetEmail,
renderWelcomeEmail,
} from '@/components/emails'
import { getAccessControlConfig } from '@/lib/auth/access-control'
import { sendPlanWelcomeEmail } from '@/lib/billing'
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
import {
@@ -137,16 +138,6 @@ function getMicrosoftUserInfoFromIdToken(tokens: { accessToken?: string }, provi
}
}
const blockedSignupDomains = env.BLOCKED_SIGNUP_DOMAINS
? Array.from(
new Set(
env.BLOCKED_SIGNUP_DOMAINS.split(',')
.map((d) => d.trim().toLowerCase())
.filter(Boolean)
)
)
: null
export function isEmailInDenylist(
email: string | undefined | null,
denylist: readonly string[] | null
@@ -157,10 +148,6 @@ export function isEmailInDenylist(
return denylist.some((entry) => domain === entry || domain.endsWith(`.${entry}`))
}
function isSignupEmailBlocked(email: string | undefined | null): boolean {
return isEmailInDenylist(email, blockedSignupDomains)
}
const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) =>
logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value })
)
@@ -246,7 +233,8 @@ export const auth = betterAuth({
user: {
create: {
before: async (user) => {
if (isSignupEmailBlocked(user.email)) {
const accessControl = await getAccessControlConfig()
if (isEmailInDenylist(user.email, accessControl.blockedSignupDomains)) {
throw new Error('Sign-ups from this email domain are not allowed.')
}
return { data: user }
@@ -813,51 +801,52 @@ export const auth = betterAuth({
})
}
if (
(ctx.path.startsWith('/sign-in') || ctx.path.startsWith('/sign-up')) &&
(env.ALLOWED_LOGIN_EMAILS || env.ALLOWED_LOGIN_DOMAINS)
) {
const isSignIn = ctx.path.startsWith('/sign-in')
const isSignUp = ctx.path.startsWith('/sign-up')
if (isSignIn || isSignUp) {
const accessControl = await getAccessControlConfig()
const requestEmail = ctx.body?.email?.toLowerCase()
if (requestEmail) {
let isAllowed = false
if (env.ALLOWED_LOGIN_EMAILS) {
const allowedEmails = env.ALLOWED_LOGIN_EMAILS.split(',').map((email) =>
email.trim().toLowerCase()
)
isAllowed = allowedEmails.includes(requestEmail)
}
if (!isAllowed && env.ALLOWED_LOGIN_DOMAINS) {
const allowedDomains = env.ALLOWED_LOGIN_DOMAINS.split(',').map((domain) =>
domain.trim().toLowerCase()
)
const emailDomain = requestEmail.split('@')[1]
isAllowed = emailDomain && allowedDomains.includes(emailDomain)
}
// Banning an existing account is owned by better-auth's admin plugin (a
// `session.create.before` hook that blocks banned users at sign-in across
// all providers), so it is not re-checked here.
const hasAllowlist =
accessControl.allowedLoginEmails.length > 0 ||
accessControl.allowedLoginDomains.length > 0
if (hasAllowlist && requestEmail) {
const emailDomain = requestEmail.split('@')[1]
const isAllowed =
accessControl.allowedLoginEmails.includes(requestEmail) ||
(!!emailDomain && accessControl.allowedLoginDomains.includes(emailDomain))
if (!isAllowed) {
throw new APIError('FORBIDDEN', {
message: 'Access restricted. Please contact your administrator.',
})
}
}
}
if (ctx.path.startsWith('/sign-up') && isSignupEmailBlocked(ctx.body?.email)) {
throw new APIError('FORBIDDEN', {
message: 'Sign-ups from this email domain are not allowed.',
})
}
if (isSignupMxValidationEnabled && ctx.path.startsWith('/sign-up/email') && ctx.body?.email) {
const mxCheck = await validateSignupEmailMx(ctx.body.email)
if (!mxCheck.allowed) {
if (isSignUp && isEmailInDenylist(ctx.body?.email, accessControl.blockedSignupDomains)) {
throw new APIError('FORBIDDEN', {
message: 'Sign-ups from this email domain are not allowed.',
})
}
if (
isSignupMxValidationEnabled &&
ctx.path.startsWith('/sign-up/email') &&
ctx.body?.email
) {
const mxCheck = await validateSignupEmailMx(
ctx.body.email,
accessControl.blockedEmailMxHosts
)
if (!mxCheck.allowed) {
throw new APIError('FORBIDDEN', {
message: 'Sign-ups from this email domain are not allowed.',
})
}
}
}
if (ctx.path === '/sign-up/email' && ctx.body?.email) {
+136
View File
@@ -0,0 +1,136 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSend } = vi.hoisted(() => ({
mockSend: vi.fn(),
}))
vi.mock('@aws-sdk/client-appconfigdata', () => ({
AppConfigDataClient: class {
send = mockSend
},
StartConfigurationSessionCommand: class {
__type = 'start'
constructor(public input: unknown) {}
},
GetLatestConfigurationCommand: class {
__type = 'get'
constructor(public input: unknown) {}
},
}))
import { fetchAppConfigProfile } from '@/lib/core/config/appconfig'
const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value))
let counter = 0
/** Unique identifiers per test so the module-level cache never bleeds across tests. */
function uniqueIds() {
counter += 1
return { application: `app-${counter}`, environment: `env-${counter}`, profile: 'access-control' }
}
describe('fetchAppConfigProfile', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts a session then returns the parsed configuration', async () => {
mockSend.mockImplementation((command: { __type: string }) => {
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
return Promise.resolve({
Configuration: encode({ blockedSignupDomains: ['spam.example'] }),
NextPollConfigurationToken: 'tok-2',
})
})
const result = await fetchAppConfigProfile(
uniqueIds(),
(json) => json as Record<string, unknown>
)
expect(result).toEqual({ blockedSignupDomains: ['spam.example'] })
const sentTypes = mockSend.mock.calls.map(([c]) => c.__type)
expect(sentTypes).toEqual(['start', 'get'])
})
it('returns null when the cold fetch fails (never throws)', async () => {
mockSend.mockRejectedValue(new Error('appconfig down'))
const result = await fetchAppConfigProfile(uniqueIds(), (json) => json)
expect(result).toBeNull()
})
it('applies the parse function to the decoded JSON', async () => {
mockSend.mockImplementation((command: { __type: string }) => {
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
return Promise.resolve({
Configuration: encode({ count: 2 }),
NextPollConfigurationToken: 'tok-2',
})
})
const result = await fetchAppConfigProfile(
uniqueIds(),
(json) => (json as { count: number }).count * 10
)
expect(result).toBe(20)
})
it('warms the cache on an empty payload and does not re-poll (unseeded profile)', async () => {
mockSend.mockImplementation((command: { __type: string }) => {
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
return Promise.resolve({
Configuration: new Uint8Array(),
NextPollConfigurationToken: 'tok-2',
NextPollIntervalInSeconds: 60,
})
})
const ids = uniqueIds()
expect(await fetchAppConfigProfile(ids, (json) => json)).toBeNull()
const callsAfterFirst = mockSend.mock.calls.length
expect(await fetchAppConfigProfile(ids, (json) => json)).toBeNull()
expect(mockSend.mock.calls.length).toBe(callsAfterFirst)
})
it('keeps the session on a parse error (no re-StartConfigurationSession, no throw)', async () => {
mockSend.mockImplementation((command: { __type: string }) => {
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
return Promise.resolve({
Configuration: new TextEncoder().encode('not json{'),
NextPollConfigurationToken: 'tok-2',
NextPollIntervalInSeconds: 60,
})
})
const ids = uniqueIds()
expect(await fetchAppConfigProfile(ids, (json) => json)).toBeNull()
// Network round trip succeeded, so exactly one session was started despite the
// parse failure — the rotated token was preserved, not discarded.
expect(mockSend.mock.calls.filter(([c]) => c.__type === 'start')).toHaveLength(1)
})
it('dedupes concurrent cold fetches into a single poll', async () => {
mockSend.mockImplementation((command: { __type: string }) => {
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
return Promise.resolve({
Configuration: encode({ x: 1 }),
NextPollConfigurationToken: 'tok-2',
})
})
const ids = uniqueIds()
const [a, b] = await Promise.all([
fetchAppConfigProfile(ids, (json) => json),
fetchAppConfigProfile(ids, (json) => json),
])
expect(a).toEqual({ x: 1 })
expect(b).toEqual({ x: 1 })
expect(mockSend.mock.calls.map(([c]) => c.__type)).toEqual(['start', 'get'])
})
})
+167
View File
@@ -0,0 +1,167 @@
import {
AppConfigDataClient,
GetLatestConfigurationCommand,
type GetLatestConfigurationCommandOutput,
StartConfigurationSessionCommand,
} from '@aws-sdk/client-appconfigdata'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws'
import { env } from '@/lib/core/config/env'
const logger = createLogger('AppConfig')
const DEFAULT_TTL_MS = 30_000
export interface AppConfigProfileIdentifiers {
application: string
environment: string
profile: string
}
interface CacheEntry<T> {
/** Last successfully parsed value, or `null` if the config is empty/unseeded. */
value: T | null
/** True once any poll has completed (success, empty payload, or error). */
loaded: boolean
/** Token for the next `GetLatestConfiguration` poll, rotated on each call. */
nextToken: string | undefined
expiresAt: number
/** In-flight poll, shared so concurrent callers don't each hit AppConfig. */
inflight: Promise<T | null> | null
}
const cache = new Map<string, CacheEntry<unknown>>()
let client: AppConfigDataClient | null = null
/**
* Lazily construct the AppConfig data-plane client. Never instantiated unless a
* caller actually fetches a profile, so deployments without AppConfig configured
* never reach for AWS credentials.
*/
function getClient(): AppConfigDataClient {
if (!client) {
client = new AppConfigDataClient({
region: env.AWS_REGION,
credentials: getAwsCredentialsFromEnv(),
})
}
return client
}
function cacheKey(ids: AppConfigProfileIdentifiers): string {
return `${ids.application}/${ids.environment}/${ids.profile}`
}
/**
* Run one AppConfig poll for `entry`: starts a session if no token is held, then
* calls `GetLatestConfiguration`. An empty payload means "unchanged" (or an
* unseeded profile) and the previous value is kept. Any error is logged and the
* last good value is retained. Marks the entry `loaded` on any outcome so callers
* never re-block on the cold path, and honors AppConfig's `NextPollInterval` so we
* don't poll faster than the server allows (which would throttle).
*/
async function poll<T>(
ids: AppConfigProfileIdentifiers,
parse: (json: unknown) => T,
entry: CacheEntry<T>
): Promise<T | null> {
let response: GetLatestConfigurationCommandOutput
try {
const dataClient = getClient()
if (!entry.nextToken) {
const session = await dataClient.send(
new StartConfigurationSessionCommand({
ApplicationIdentifier: ids.application,
EnvironmentIdentifier: ids.environment,
ConfigurationProfileIdentifier: ids.profile,
})
)
entry.nextToken = session.InitialConfigurationToken
}
response = await dataClient.send(
new GetLatestConfigurationCommand({ ConfigurationToken: entry.nextToken })
)
entry.nextToken = response.NextPollConfigurationToken ?? entry.nextToken
} catch (error) {
// Network/session failure: drop the token so the next attempt starts a fresh
// session (handles expired or invalid tokens). Mark loaded + back off so we
// serve the fallback and retry in the background rather than blocking every
// request during an outage.
entry.nextToken = undefined
entry.expiresAt = Date.now() + DEFAULT_TTL_MS
entry.loaded = true
logger.error('AppConfig fetch failed; serving last known value', {
profile: cacheKey(ids),
error: getErrorMessage(error),
})
return entry.value
}
// Parse outside the network try: a decode/parse error must NOT discard the
// already-rotated session token — the round trip succeeded, so the next poll
// can reuse it instead of opening a new session. Keep the last good value.
try {
if (response.Configuration && response.Configuration.length > 0) {
const text = new TextDecoder().decode(response.Configuration)
entry.value = parse(JSON.parse(text))
}
} catch (error) {
logger.error('AppConfig response parse failed; serving last known value', {
profile: cacheKey(ids),
error: getErrorMessage(error),
})
}
const intervalMs = (response.NextPollIntervalInSeconds ?? 60) * 1000
entry.expiresAt = Date.now() + Math.max(DEFAULT_TTL_MS, intervalMs)
entry.loaded = true
return entry.value
}
/**
* Fetch and cache a single AppConfig configuration profile as JSON.
*
* Profile-agnostic: pass the `application`/`environment` (from env) and a
* `profile` constant owned by the calling feature. Uses an in-process TTL cache
* with stale-while-revalidate — a warm cache returns immediately and refreshes
* in the background once the TTL lapses, so no request blocks on the AppConfig
* round trip after the first (cold) fetch. Concurrent callers share one in-flight
* poll (avoids racing the rotating session token). Returns `null` when the config
* is empty/unseeded or the first fetch fails.
*/
export async function fetchAppConfigProfile<T>(
ids: AppConfigProfileIdentifiers,
parse: (json: unknown) => T
): Promise<T | null> {
const key = cacheKey(ids)
const entry = (cache.get(key) as CacheEntry<T> | undefined) ?? {
value: null,
loaded: false,
nextToken: undefined,
expiresAt: 0,
inflight: null,
}
cache.set(key, entry)
// Cold: never polled — await a single shared poll so concurrent callers don't
// each hit AppConfig (and don't race the rotating session token).
if (!entry.loaded) {
entry.inflight ??= poll(ids, parse, entry).finally(() => {
entry.inflight = null
})
return entry.inflight
}
// Warm but stale: serve cached value, refresh once in the background.
if (Date.now() >= entry.expiresAt && !entry.inflight) {
entry.inflight = poll(ids, parse, entry).finally(() => {
entry.inflight = null
})
}
return entry.value
}
+24
View File
@@ -0,0 +1,24 @@
import { env } from '@/lib/core/config/env'
interface AwsCredentials {
accessKeyId: string
secretAccessKey: string
}
/**
* Explicit AWS credentials from the environment, or `undefined` to defer to the
* default AWS provider chain (the ECS task role in our deployments).
*
* Shared by every AWS SDK client (S3, AppConfig, …) so credential resolution is
* identical everywhere: explicit keys when both `AWS_ACCESS_KEY_ID` and
* `AWS_SECRET_ACCESS_KEY` are set (self-hosted, trigger.dev workers), otherwise
* the instance/task role.
*/
export function getAwsCredentialsFromEnv(): AwsCredentials | undefined {
return env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY
? {
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
}
: undefined
}
+4
View File
@@ -222,6 +222,10 @@ export const env = createEnv({
S3_ENDPOINT: z.string().optional(), // Custom endpoint for S3-compatible storage (Cloudflare R2, MinIO, Backblaze B2). Leave unset for AWS S3
S3_FORCE_PATH_STYLE: z.string().optional(), // Force path-style addressing (MinIO/Ceph RGW). Defaults to false (AWS S3, R2). Coerced via envBoolean at the consumption site
// Dynamic config - AWS AppConfig (hosted source of truth for signup/login gating lists; unset => env-var fallback)
APPCONFIG_APPLICATION: z.string().optional(), // AppConfig application id/name. On hosted deployments, when set with APPCONFIG_ENVIRONMENT, gating lists come from AppConfig instead of env vars
APPCONFIG_ENVIRONMENT: z.string().optional(), // AppConfig environment id/name. Profile name is an app-side constant ('access-control'), not an env var
// Cloud Storage - Azure Blob
AZURE_ACCOUNT_NAME: z.string().optional(), // Azure storage account name
AZURE_ACCOUNT_KEY: z.string().optional(), // Azure storage account key
@@ -96,6 +96,15 @@ export const isSignupEmailValidationEnabled = isTruthy(env.SIGNUP_EMAIL_VALIDATI
*/
export const isSignupMxValidationEnabled = isTruthy(env.SIGNUP_MX_VALIDATION_ENABLED)
/**
* Is AWS AppConfig the source of truth for the signup/login gating lists.
* Hosted-only and requires both AppConfig identifiers (injected by the infra
* stack). Self-hosted/OSS deployments always use the env-var fallback, so the
* AppConfig client is never reached off-hosted.
*/
export const isAppConfigEnabled =
isHosted && Boolean(env.APPCONFIG_APPLICATION && env.APPCONFIG_ENVIRONMENT)
/**
* Is Trigger.dev enabled for async job processing
*/
@@ -3,23 +3,14 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockResolveMx, envRef } = vi.hoisted(() => ({
const { mockResolveMx } = 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[]) =>
@@ -28,29 +19,29 @@ const mx = (...hosts: string[]) =>
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')
const result = await validateSignupEmailMx('user@rotated-domain.test', [
'blocked-backend.example',
])
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')
mockResolveMx.mockResolvedValue(mx('MX1.Blocked-Backend.Example'))
const result = await validateSignupEmailMx('user@another-domain.test', [
'blocked-backend.example',
])
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')
const result = await validateSignupEmailMx('user@rotated-domain.test', [])
expect(result.allowed).toBe(true)
})
@@ -58,32 +49,32 @@ describe('validateSignupEmailMx', () => {
mockResolveMx.mockResolvedValue(
mx('gmail-smtp-in.l.google.com', 'alt1.gmail-smtp-in.l.google.com')
)
const result = await validateSignupEmailMx('real.person@gmail.com')
const result = await validateSignupEmailMx('real.person@gmail.com', ['blocked-backend.example'])
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')
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')
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')
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')
const result = await validateSignupEmailMx('not-an-email', [])
expect(result.allowed).toBe(true)
expect(mockResolveMx).not.toHaveBeenCalled()
})
@@ -2,29 +2,11 @@ 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
@@ -41,10 +23,18 @@ export interface SignupEmailCheck {
* 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): Promise<SignupEmailCheck> {
export async function validateSignupEmailMx(
email: string,
blockedMxHosts: string[]
): Promise<SignupEmailCheck> {
const domain = email.split('@')[1]?.toLowerCase()
if (!domain) return { allowed: true }
@@ -80,10 +70,9 @@ export async function validateSignupEmailMx(email: string): Promise<SignupEmailC
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))
return blockedMxHosts.some((host) => exchange.includes(host))
})
if (match) {
+2 -8
View File
@@ -14,7 +14,7 @@ import {
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { env } from '@/lib/core/config/env'
import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws'
import {
assertKnownSizeWithinLimit,
readNodeStreamToBufferWithLimit,
@@ -57,13 +57,7 @@ export function getS3Client(): S3Client {
region,
endpoint: S3_CONFIG.endpoint,
forcePathStyle: S3_CONFIG.forcePathStyle,
credentials:
env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY
? {
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
}
: undefined,
credentials: getAwsCredentialsFromEnv(),
})
return _s3Client
+1
View File
@@ -34,6 +34,7 @@
"@1password/sdk": "0.3.1",
"@a2a-js/sdk": "0.3.7",
"@anthropic-ai/sdk": "0.71.2",
"@aws-sdk/client-appconfigdata": "3.1032.0",
"@aws-sdk/client-athena": "3.1032.0",
"@aws-sdk/client-bedrock-runtime": "3.1032.0",
"@aws-sdk/client-cloudformation": "3.1032.0",
+3
View File
@@ -88,6 +88,7 @@
"@1password/sdk": "0.3.1",
"@a2a-js/sdk": "0.3.7",
"@anthropic-ai/sdk": "0.71.2",
"@aws-sdk/client-appconfigdata": "3.1032.0",
"@aws-sdk/client-athena": "3.1032.0",
"@aws-sdk/client-bedrock-runtime": "3.1032.0",
"@aws-sdk/client-cloudformation": "3.1032.0",
@@ -560,6 +561,8 @@
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-appconfigdata": ["@aws-sdk/client-appconfigdata@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-stream": "^4.5.23", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-gh/cmEFDN97XJBWRLT0usnWnTDEm+cqgOIffTJGP68xCgj28EkSHnN5vtdy2QaZjj7/n/sKOlqIKONZUeonRpA=="],
"@aws-sdk/client-athena": ["@aws-sdk/client-athena@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-/3RrC4J644U1ZlqcGyGCRf2cyCH/xWs2B6PewlKWeyTq2uWSRtY+v5CkEQ51fRm2Y5wfhuxoU9FO1jKIKm9fSA=="],
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/eventstream-handler-node": "^3.972.14", "@aws-sdk/middleware-eventstream": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/middleware-websocket": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/token-providers": "3.1032.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-stream": "^4.5.23", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fSRz/48As9c3DeS+9ZWd7kk9171pJntCCuehHBDeprD9CPF+C+ATaVNJ5SOLE5RIBR2IHOVTwjAgJt/nkS/6Yg=="],