/** * CORS-allowlist matcher for the marketing-funnel capture endpoint (Phase 05 #78). * * The endpoint is intentionally public so external landing-pages can ingest * leads, but the Origin header is matched against `MARKETING_ALLOWED_ORIGINS` * (comma-separated env list). Wildcards are supported via a single leading `*` * in the host (e.g. `https://*.toolctrl.de`). * * Server-to-server bots can spoof Origin — this layer is one of three (origin * allowlist, rate-limit, optional Turnstile). It does NOT replace rate-limiting. */ export function parseAllowedOrigins(raw: string | undefined | null): string[] { if (!raw) return []; return raw .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0); } /** * Normalise an origin string to `https://host` (no trailing slash, no path). * Returns null when the input is not a valid origin URL. */ function normaliseOrigin(value: string | null | undefined): string | null { if (!value) return null; try { const u = new URL(value); return `${u.protocol}//${u.host}`; } catch { return null; } } /** * Match the request `Origin` header against an allowlist with wildcard support. * * - Empty allowlist + non-null origin → reject (fail-closed default). * - Empty allowlist + null/missing origin → reject (server-to-server bot). * - Wildcard entries match the host portion only (e.g. `https://*.example.com` * matches `https://app.example.com` but not `https://example.com`). */ export function matchAllowedOrigin(origin: string | null | undefined, allowed: readonly string[]): boolean { const normalised = normaliseOrigin(origin); if (!normalised) return false; if (allowed.length === 0) return false; for (const entry of allowed) { const entryNorm = normaliseOrigin(entry); if (!entryNorm) { // Allow bare host wildcard like `https://*.example.com` which is not a // valid URL because of the literal `*`. if (entry.includes("*")) { if (wildcardMatch(entry, normalised)) return true; } continue; } if (entryNorm === normalised) return true; if (entry.includes("*") && wildcardMatch(entry, normalised)) return true; } return false; } function wildcardMatch(pattern: string, candidate: string): boolean { // Convert wildcard pattern (`https://*.example.com`) to anchored regex. // Only `*` is special; everything else is escaped. const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[a-zA-Z0-9-]+"); const re = new RegExp(`^${escaped}$`); return re.test(candidate); }