Skip to content

CSRF Defense — Synchronizer Tokens + SameSite Cookies

Defends against

Cross-Site Request Forgery (CSRF) — forged notification-email change via POST /api/settings/email.

Prerequisites

  • Local INSECURE-threat-intel-app stack (Docker setup).
  • You can change the email from the /email-update.html PoC page, triggering the "Found CSRF" flag.
  • Editor open on exercise-files/web-attacks-101/INSECURE-threat-intel-app.

Principle

CSRF abuses the fact that browsers automatically attach cookies to requests, even ones triggered by another site. The server sees a valid session cookie and performs the state change, unable to tell the user did not intend it.

The defense is to require a secret the attacker's page cannot read or guess on every state-changing request:

  1. Synchronizer token (primary). The server issues a random token tied to the session, embeds it in legitimate forms, and rejects any state-changing request whose token is missing or wrong. A cross-site page cannot read it (same-origin policy), so it cannot forge a valid request.
  2. SameSite cookies (defense in depth). SameSite=Strict/Lax tells the browser not to send the session cookie on cross-site requests, blocking the classic cross-domain POST.

Two independent locks: even if one cookie policy is bypassed, the token still gates the action.


Step 1 — Locate the vulnerable endpoint

POST /api/settings/email checks only that a session exists, then writes — no anti-CSRF token:

```6:28:exercise-files/web-attacks-101/INSECURE-threat-intel-app/src/pages/api/settings/email.ts export const POST: APIRoute = async ({ request, locals, redirect }) => { const username = locals.username; if (!username) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }); } // VULNERABLE: only checks that a session cookie exists — no CSRF token is required, // so a cross-site auto-submitting form can drive this state change.

const contentType = request.headers.get("content-type") ?? ""; let email = ""; if (contentType.includes("application/json")) { const body = (await request.json()) as { email?: string }; email = String(body.email ?? "").trim(); } else { const form = await request.formData(); email = String(form.get("email") ?? "").trim(); }

if (!email) { return redirect("/settings?error=missing", 302); }

await updateEmail(username, email); // VULNERABLE: state change reached with cookie alone return redirect("/settings?updated=1", 302); };

## Step 2 — Issue and verify a synchronizer token

Add a small CSRF helper. The token is bound to the session and delivered via a readable cookie + hidden form field (the **double-submit** pattern, which needs no server-side token store):

```ts
// src/lib/csrf.ts
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";

const CSRF_COOKIE = "csrf_token";

function secret(): string {
  const s = process.env.AUTH_SECRET;
  if (!s) throw new Error("AUTH_SECRET is required");
  return s;
}

/** Token = random value + HMAC, so the server can verify without storage. */
export function issueCsrfToken(): { token: string; cookie: string } {
  const raw = randomBytes(32).toString("base64url");
  const mac = createHmac("sha256", secret()).update(raw).digest("base64url");
  const token = `${raw}.${mac}`;
  // Readable by JS so forms can echo it, but only same-origin pages can read it.
  const cookie = `${CSRF_COOKIE}=${token}; Path=/; SameSite=Strict`;
  return { token, cookie };
}

function valid(token: string | undefined): boolean {
  if (!token) return false;
  const [raw, mac] = token.split(".");
  if (!raw || !mac) return false;
  const expected = createHmac("sha256", secret()).update(raw).digest("base64url");
  const a = Buffer.from(mac);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}

/** Both the cookie copy and the submitted copy must be present, valid, and equal. */
export function verifyCsrf(cookieToken: string | undefined, submitted: string | undefined): boolean {
  return valid(cookieToken) && valid(submitted) && cookieToken === submitted;
}

Then enforce it in the endpoint, before any state change:

import { verifyCsrf } from "../../../lib/csrf";

export const POST: APIRoute = async ({ request, locals, redirect }) => {
  const username = locals.username;
  if (!username) {
    return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
  }

  const form = await request.formData();
  const submitted = String(form.get("csrf_token") ?? "");
  const cookieToken = readCookie(request, "csrf_token");
  if (!verifyCsrf(cookieToken, submitted)) {
    return new Response(JSON.stringify({ error: "Invalid CSRF token" }), { status: 403 });
  }

  const email = String(form.get("email") ?? "").trim();
  if (!email) return redirect("/settings?error=missing", 302);

  await updateEmail(username, email);
  return redirect("/settings?updated=1", 302);
};

The attacker's /email-update.html page cannot read the victim's csrf_token cookie (same-origin policy) and cannot guess the HMAC, so it cannot supply a matching csrf_token field → 403.

Step 3 — Embed the token in the real form

In src/pages/settings.astro, issue a token when rendering and include it as a hidden field:

---
import { issueCsrfToken } from '../lib/csrf';
const { token, cookie } = issueCsrfToken();
Astro.response.headers.append('Set-Cookie', cookie);
---
<form class="mt-6 space-y-3 ..." method="post" action="/api/settings/email">
  <input type="hidden" name="csrf_token" value={token} />
  <label class="block text-xs text-zinc-500" for="email">Update notification email</label>
  <input class={inputClass} id="email" name="email" type="email" required />
  <button type="submit" class="...">Save</button>
</form>

The legitimate form now carries a token that matches the cookie; the cross-site PoC does not.

Move the session cookie to SameSite=Strict so the browser will not attach it to cross-site requests at all. This is the second lock:

``83:85:exercise-files/web-attacks-101/INSECURE-threat-intel-app/src/lib/auth.ts export function sessionCookieHeader(token: string): string { return\({SESSION_COOKIE}=\)`; }

Change `SameSite=Lax` → `SameSite=Strict` (and add `Secure` once served over HTTPS):

```ts
return `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${...}`;
}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_MAX_AGE_MS / 1000)

Why the lab PoC is same-origin

/email-update.html is hosted on localhost:8081 — the same origin as the app — specifically so SameSite does not block it and you can observe the mechanism. A real attacker page on evil.test would already be blocked from sending the cookie by SameSite. The synchronizer token is what defends even against same-origin forgery and subtle SameSite gaps, which is why it is the primary control.

Step 5 — Also enable Astro's origin check

This app disabled Astro's built-in origin check to keep the lab simple:

```13:15:exercise-files/web-attacks-101/INSECURE-threat-intel-app/astro.config.mjs security: { checkOrigin: false, },

Re-enabling `checkOrigin: true` makes Astro verify the `Origin`/`Referer` on form POSTs — a free additional CSRF layer. (You may need to keep browsing on a single host, e.g. always `localhost`, after enabling it.)

## Step 6 — Verify the fix

```bash
npm run docker:down && npm run docker:up

Test Before After (fixed)
Save email from /settings form Works Works (token present)
Load /email-update.html PoC Email silently changes 403 Invalid CSRF token; email unchanged
Replay POST in Burp without csrf_token 302 success 403

The "Found CSRF" flag (detectCsrfEmail) fires when the email becomes …@evil.lab. With the token enforced, the forged POST is rejected, the email never changes, and the flag stops firing.


Defense in depth

  • Synchronizer (or double-submit) token on every state-changing POST/PUT/PATCH/DELETE.
  • SameSite=Strict/Lax + Secure + HttpOnly on session cookies.
  • Origin/Referer validation for state-changing requests (Astro checkOrigin, or manual checks).
  • Custom-header requirement for JSON APIs (X-Requested-With) — simple cross-site forms cannot set custom headers, and CORS gates the rest.
  • Re-authentication / step-up for high-risk actions (password, email, MFA changes).
  • Keep GET side-effect free — never change state on a GET.

Verification checklist

  • [ ] Every state-changing endpoint verifies a CSRF token (or equivalent) before acting.
  • [ ] Legitimate forms include the token; it is validated server-side.
  • [ ] Session cookie is HttpOnly + SameSite=Strict (+ Secure on HTTPS).
  • [ ] The /email-update.html PoC now returns 403 and does not change the email.
  • [ ] The "Found CSRF" flag no longer appears.