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.htmlPoC 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:
- 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.
SameSitecookies (defense in depth).SameSite=Strict/Laxtells 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.
Step 4 — Tighten the session cookie¶
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=${...}`;
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+HttpOnlyon 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
GETside-effect free — never change state on aGET.
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(+Secureon HTTPS). - [ ] The
/email-update.htmlPoC now returns403and does not change the email. - [ ] The "Found CSRF" flag no longer appears.