71 lines
2.5 KiB
TypeScript
71 lines
2.5 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|