[]previous and next page

CSRF

Browsers attach the user's cookies to a request that another site triggers.

For agentsUpdated

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:

  1. Safe method: allowed (safe-method).
  2. Sec-Fetch-Site: same-origin: allowed (same-origin).
  3. Any other Sec-Fetch-Site value: allowed only if Origin is a trusted origin (trusted-origin); otherwise refused as same-site, user-initiated-unsafe (none) or cross-site.
  4. No Sec-Fetch-Site, Origin present: null is 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).
  5. No Origin, Referer present: same rule (referer-match, referer-mismatch).
  6. None of the three: refused (missing-origin), or allowed with security.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.

src/routes/api/hook.tsts
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 });
};
src/server/ping.tsts
import { serverFn } from "@shadow-js/dusk/server";

export const ping = serverFn({}, async () => "pong");