53 lines
1.8 KiB
TypeScript
53 lines
1.8 KiB
TypeScript
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";
|
|
}
|
|
}
|