import type { IncomingMessage, ServerResponse } from 'node:http'; import { queryOne } from './db.js'; import { isLocalTenantHost } from '../features/tenant/locator.js'; export type CorsDecisionReason = | 'no-origin' | 'wildcard' | 'static-origin' | 'tenant-domain' | 'invalid-origin' | 'tenant-domain-disabled' | 'tenant-domain-lookup-failed'; export interface CorsDecision { allowed: boolean; allowOrigin: string | null; reason: CorsDecisionReason; } export interface CorsPolicyOptions { staticOrigins: string[]; tenantDomainsEnabled: boolean; positiveCacheTtlMs: number; negativeCacheTtlMs: number; maxCacheEntries: number; lookupTenantDomain?: (host: string) => Promise; now?: () => number; onLookupError?: (error: unknown, host: string) => void; } interface NormalizedOrigin { origin: string; hostname: string; protocol: 'http:' | 'https:'; hasNonDefaultPort: boolean; } interface CachedTenantDomain { allowed: boolean; expiresAt: number; lookupFailed: boolean; } const CORS_ALLOW_METHODS = 'GET,POST,PUT,PATCH,DELETE,OPTIONS'; const CORS_ALLOW_HEADERS = 'content-type,authorization,x-request-id,x-tenant-id,x-tenant-code,x-user-id,x-platform-admin-key'; function firstHeader(req: IncomingMessage, name: string) { if (name.toLowerCase() === 'origin') { const originHeaders = req.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === 'origin'); if (originHeaders.length > 1) return '__multiple_origin_headers__'; } const value = req.headers[name.toLowerCase()]; if (Array.isArray(value)) return value[0] || ''; return value || ''; } function addVaryHeader(res: ServerResponse, value: string) { const current = res.getHeader('vary'); const values = (Array.isArray(current) ? current : String(current || '').split(',')) .map(item => String(item).trim()) .filter(Boolean); if (!values.some(item => item.toLowerCase() === value.toLowerCase())) values.push(value); res.setHeader('vary', values.join(', ')); } export function normalizeCorsOrigin(value: string): NormalizedOrigin | null { const raw = value.trim(); if (!raw || raw === 'null' || raw.includes(',') || /\s/.test(raw)) return null; try { const parsed = new URL(raw); if (!['http:', 'https:'].includes(parsed.protocol)) return null; if (parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) return null; if (!parsed.hostname || parsed.hostname.endsWith('.')) return null; const protocol = parsed.protocol as 'http:' | 'https:'; const defaultPort = protocol === 'https:' ? '443' : '80'; return { origin: parsed.origin.toLowerCase(), hostname: parsed.hostname.toLowerCase(), protocol, hasNonDefaultPort: Boolean(parsed.port && parsed.port !== defaultPort), }; } catch { return null; } } async function lookupActiveTenantDomain(host: string) { const row = await queryOne<{ allowed: boolean }>( ` select exists ( select 1 from public.tenant_domains d join public.tenants t on t.id = d.tenant_id where d.host = $1 and d.status = 'active' and t.status = 'active' ) as allowed `, [host], ); return row?.allowed === true; } export class CorsPolicy { private readonly staticOrigins: Set; private readonly allowAll: boolean; private readonly tenantDomainsEnabled: boolean; private readonly positiveCacheTtlMs: number; private readonly negativeCacheTtlMs: number; private readonly maxCacheEntries: number; private readonly lookupTenantDomain: (host: string) => Promise; private readonly now: () => number; private readonly onLookupError?: (error: unknown, host: string) => void; private readonly cache = new Map(); private readonly pendingLookups = new Map>(); constructor(options: CorsPolicyOptions) { this.allowAll = options.staticOrigins.includes('*'); this.staticOrigins = new Set( options.staticOrigins .filter(origin => origin !== '*') .map(origin => normalizeCorsOrigin(origin)?.origin || '') .filter(Boolean), ); this.tenantDomainsEnabled = options.tenantDomainsEnabled; this.positiveCacheTtlMs = options.positiveCacheTtlMs; this.negativeCacheTtlMs = options.negativeCacheTtlMs; this.maxCacheEntries = options.maxCacheEntries; this.lookupTenantDomain = options.lookupTenantDomain || lookupActiveTenantDomain; this.now = options.now || Date.now; this.onLookupError = options.onLookupError; } async evaluate(rawOrigin: string): Promise { if (!rawOrigin.trim()) return { allowed: true, allowOrigin: null, reason: 'no-origin' }; const normalized = normalizeCorsOrigin(rawOrigin); if (!normalized) return { allowed: false, allowOrigin: null, reason: 'invalid-origin' }; if (this.allowAll) return { allowed: true, allowOrigin: '*', reason: 'wildcard' }; if (this.staticOrigins.has(normalized.origin)) { return { allowed: true, allowOrigin: normalized.origin, reason: 'static-origin' }; } // Tenant browser domains are production HTTPS hosts. Development ports belong // in the explicit static allowlist and must never be inferred from Host headers. if ( !this.tenantDomainsEnabled || normalized.protocol !== 'https:' || normalized.hasNonDefaultPort || isLocalTenantHost(normalized.hostname) ) { return { allowed: false, allowOrigin: null, reason: 'tenant-domain-disabled' }; } const lookup = await this.lookupWithCache(normalized.hostname); if (!lookup.allowed) { return { allowed: false, allowOrigin: null, reason: lookup.lookupFailed ? 'tenant-domain-lookup-failed' : 'tenant-domain-disabled', }; } return { allowed: true, allowOrigin: normalized.origin, reason: 'tenant-domain' }; } private async lookupWithCache(host: string) { const now = this.now(); const cached = this.cache.get(host); if (cached && cached.expiresAt > now) { this.cache.delete(host); this.cache.set(host, cached); return { allowed: cached.allowed, lookupFailed: cached.lookupFailed }; } if (cached) this.cache.delete(host); const pending = this.pendingLookups.get(host); if (pending) return pending; const lookup = this.performLookup(host); this.pendingLookups.set(host, lookup); try { return await lookup; } finally { this.pendingLookups.delete(host); } } private async performLookup(host: string) { let allowed = false; let lookupFailed = false; try { allowed = await this.lookupTenantDomain(host); } catch (error) { lookupFailed = true; this.onLookupError?.(error, host); } const ttlMs = allowed ? this.positiveCacheTtlMs : this.negativeCacheTtlMs; this.storeCache(host, { allowed, expiresAt: this.now() + ttlMs, lookupFailed }); return { allowed, lookupFailed }; } private storeCache(host: string, entry: CachedTenantDomain) { this.cache.delete(host); while (this.cache.size >= this.maxCacheEntries) { const oldest = this.cache.keys().next().value as string | undefined; if (!oldest) break; this.cache.delete(oldest); } this.cache.set(host, entry); } } export async function authorizeCorsRequest(req: IncomingMessage, res: ServerResponse, policy: CorsPolicy) { const decision = await policy.evaluate(firstHeader(req, 'origin')); res.setHeader('access-control-allow-methods', CORS_ALLOW_METHODS); res.setHeader('access-control-allow-headers', CORS_ALLOW_HEADERS); res.setHeader('access-control-expose-headers', 'x-request-id'); if (decision.allowOrigin) res.setHeader('access-control-allow-origin', decision.allowOrigin); if (decision.reason !== 'wildcard') addVaryHeader(res, 'Origin'); return decision; }