CSRF
Browsers attach the user's cookies to a request that another site triggers.
Browsers attach the user's cookies to a request that another site triggers. Dusk rejects such a request before any
of your code runs: guards, then the CSRF check, then middleware. The check applies to pages, API routes and server
functions, to every method except GET, HEAD and OPTIONS. checkCsrf(request, context) decides in this order, and the
first rule that applies wins:
- Safe method: allowed (
safe-method). Sec-Fetch-Site: same-origin: allowed (same-origin).- Any other
Sec-Fetch-Sitevalue: allowed only ifOriginis a trusted origin (trusted-origin); otherwise refused assame-site,user-initiated-unsafe(none) orcross-site. - No
Sec-Fetch-Site,Originpresent:nullis refused (origin-null); the request's own origin or a trusted origin is allowed (origin-match); anything else, including two origins in one header, is refused (origin-mismatch). - No
Origin,Refererpresent: same rule (referer-match,referer-mismatch). - None of the three: refused (
missing-origin), or allowed withsecurity.csrf.allowMissingOrigin(missing-origin-allowed).
CsrfReason |
Result |
|---|---|
safe-method, same-origin, trusted-origin, origin-match, referer-match, missing-origin-allowed |
allowed |
cross-site, same-site, user-initiated-unsafe, origin-null, origin-mismatch, referer-mismatch, missing-origin |
403 |
Trusted origins come from the top-level trustedOrigins (they also allow absolute redirect targets, see
Redirects) and from security.csrf.trustedOrigins. Each is normalized to scheme://host[:port]; one
that is not an http(s) origin throws at startup. The 403 message is CSRF check failed: <reason> in development and
Forbidden in production. security.csrf: false turns the check off for the whole app; do that only when something
else authenticates every request.
A webhook comes from a server, not a browser, and authenticates with a signature instead. Export csrf = false from
that API route only, and verify the signature in the handler. This route reads its secret from event.env
(Environment variables) and checks an HMAC-SHA-256 of the body in constant time with Web
Crypto.
import { HttpError, json, readTextLimited, type RequestEvent } from "@shadow-js/dusk/server";
/** This route authenticates by signature, so the browser-oriented CSRF check is off for it alone. */
export const csrf = false;
const encoder = new TextEncoder();
function fromHex(hex: string): Uint8Array<ArrayBuffer> | undefined {
if (!/^(?:[0-9a-f]{2})+$/i.test(hex)) return undefined;
const bytes = new Uint8Array(hex.length / 2);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
}
return bytes;
}
export const POST = async (event: RequestEvent) => {
const secret = event.env["WEBHOOK_SECRET"];
if (typeof secret !== "string") throw new Error("WEBHOOK_SECRET is not configured");
const body = await readTextLimited(event.request, 64 * 1024);
const signature = fromHex(event.request.headers.get("x-signature") ?? "");
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
if (signature === undefined || !(await crypto.subtle.verify("HMAC", key, signature, encoder.encode(body)))) {
throw new HttpError(401, "Invalid signature");
}
return json({ received: true });
};
import { serverFn } from "@shadow-js/dusk/server";
export const ping = serverFn({}, async () => "pong");