const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); export type MutationOriginDecision = | { allowed: true; reason: "safe-method" | "same-origin" | "non-browser" } | { allowed: false; reason: "cross-site" | "origin-mismatch" | "browser-origin-missing" }; export function validateMutationOrigin(request: Request): MutationOriginDecision { if (SAFE_METHODS.has(request.method.toUpperCase())) { return { allowed: true, reason: "safe-method" }; } const fetchSite = request.headers.get("sec-fetch-site")?.trim().toLowerCase(); if (fetchSite === "cross-site") { return { allowed: false, reason: "cross-site" }; } const requestOrigin = new URL(request.url).origin; const origin = normalizeOrigin(request.headers.get("origin")); if (origin !== null) { return origin === requestOrigin ? { allowed: true, reason: "same-origin" } : { allowed: false, reason: "origin-mismatch" }; } const referer = normalizeOrigin(request.headers.get("referer")); if (referer !== null) { return referer === requestOrigin ? { allowed: true, reason: "same-origin" } : { allowed: false, reason: "origin-mismatch" }; } if (fetchSite === "same-origin") { return { allowed: true, reason: "same-origin" }; } if (fetchSite !== undefined) { return { allowed: false, reason: "browser-origin-missing" }; } // Non-browser integrations authenticate with their own signature/token and // do not emit Fetch Metadata headers. Their route-level authentication still // applies; this gate only rejects browser-originated CSRF. return { allowed: true, reason: "non-browser" }; } function normalizeOrigin(rawValue: string | null): string | null { if (!rawValue || rawValue === "null") return null; try { return new URL(rawValue).origin; } catch { return "invalid"; } }