71 lines
2.4 KiB
TypeScript
71 lines
2.4 KiB
TypeScript
/**
|
|
* CSRF protection via the double-submit cookie pattern plus origin/fetch
|
|
* metadata validation for unsafe requests.
|
|
*/
|
|
|
|
import type { Context, Middleware } from "./context.ts";
|
|
|
|
export const CSRF_COOKIE = "wrn-csrf";
|
|
export const CSRF_HEADER = "x-csrf-token";
|
|
|
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
|
|
export interface CsrfProtectionOptions {
|
|
/** Validate Origin when present. Defaults to true. */
|
|
verifyOrigin?: boolean;
|
|
/** Additional exact origins permitted for trusted cross-origin clients. */
|
|
trustedOrigins?: string[];
|
|
/** Reject Sec-Fetch-Site: cross-site on unsafe requests. Defaults to true. */
|
|
verifyFetchMetadata?: boolean;
|
|
}
|
|
|
|
/** Ensure the CSRF cookie exists (readable by JS) and return its token. */
|
|
export function csrfToken(ctx: Context): string {
|
|
let token = ctx.cookies.get(CSRF_COOKIE);
|
|
if (!token) {
|
|
token = crypto.randomUUID().replace(/-/g, "");
|
|
ctx.cookies.set(CSRF_COOKIE, token, {
|
|
sameSite: "Lax",
|
|
path: "/",
|
|
secure: ctx.url.protocol === "https:",
|
|
});
|
|
}
|
|
return token;
|
|
}
|
|
|
|
/** Verify an unsafe request's token, origin, and browser fetch metadata. */
|
|
export function verifyCsrf(ctx: Context, options: CsrfProtectionOptions = {}): boolean {
|
|
if (SAFE_METHODS.has(ctx.req.method.toUpperCase())) return true;
|
|
|
|
if (options.verifyFetchMetadata !== false) {
|
|
const site = ctx.req.headers.get("sec-fetch-site");
|
|
if (site === "cross-site") return false;
|
|
}
|
|
|
|
if (options.verifyOrigin !== false) {
|
|
const origin = ctx.req.headers.get("origin");
|
|
if (origin) {
|
|
const trusted = new Set([ctx.url.origin, ...(options.trustedOrigins ?? [])]);
|
|
if (!trusted.has(origin)) return false;
|
|
}
|
|
}
|
|
|
|
const cookie = ctx.cookies.get(CSRF_COOKIE);
|
|
const sent = ctx.req.headers.get(CSRF_HEADER) ?? (ctx.locals._csrf as string | undefined);
|
|
return !!cookie && !!sent && timingSafeEqual(cookie, sent);
|
|
}
|
|
|
|
/** Constant-time string comparison. */
|
|
function timingSafeEqual(a: string, b: string): boolean {
|
|
let diff = a.length ^ b.length;
|
|
const max = Math.max(a.length, b.length);
|
|
for (let i = 0; i < max; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
return diff === 0;
|
|
}
|
|
|
|
/** Middleware that 403s unsafe requests with a missing/mismatched token. */
|
|
export function csrfProtection(options: CsrfProtectionOptions = {}): Middleware {
|
|
return (ctx, next) =>
|
|
verifyCsrf(ctx, options) ? next() : new Response("Invalid CSRF token", { status: 403 });
|
|
}
|