first commit
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* CSRF protection via the double-submit cookie pattern.
|
||||
*
|
||||
* The framework sets a readable `wire-csrf` cookie on page loads; the client
|
||||
* echoes it in an `x-csrf-token` header on unsafe requests (the Wire UI form
|
||||
* runtime does this automatically). The server checks header === cookie. A
|
||||
* cross-site attacker can't read the cookie to forge the header, so the request
|
||||
* is rejected — while same-origin requests pass.
|
||||
*/
|
||||
|
||||
import type { Context, Middleware } from "./context.ts";
|
||||
|
||||
export const CSRF_COOKIE = "wire-csrf";
|
||||
export const CSRF_HEADER = "x-csrf-token";
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
/** 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, "");
|
||||
// Readable by JS (double-submit needs it) but Secure on HTTPS.
|
||||
ctx.cookies.set(CSRF_COOKIE, token, {
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
secure: ctx.url.protocol === "https:",
|
||||
});
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an unsafe request's CSRF token against the cookie. Safe methods
|
||||
* (GET/HEAD/OPTIONS) always pass. The token may arrive in the `x-csrf-token`
|
||||
* header or a `_csrf` field already parsed onto `ctx.locals`.
|
||||
*/
|
||||
export function verifyCsrf(ctx: Context): boolean {
|
||||
if (SAFE_METHODS.has(ctx.req.method.toUpperCase())) return true;
|
||||
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 — the running time does not depend on where
|
||||
* the first differing byte is, so an attacker can't time-probe the token.
|
||||
*/
|
||||
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 CSRF token. */
|
||||
export function csrfProtection(): Middleware {
|
||||
return (ctx, next) =>
|
||||
verifyCsrf(ctx) ? next() : new Response("Invalid CSRF token", { status: 403 });
|
||||
}
|
||||
Reference in New Issue
Block a user