A state-changing endpoint that updates the user's email. Auth required, no other guard. Mesrai's review comment flagged the missing CSRF token check.
The vulnerable diff
// api/profile.ts
app.post("/profile/email", requireAuth, async (req, res) => {
// BUG: no CSRF check. requireAuth only verifies the session cookie.
await db.users.update(req.user.id, { email: req.body.email });
res.json({ ok: true });
});What is wrong
CSRF works because browsers automatically attach cookies to cross-site requests (subject to the SameSite flag). An attacker page that issues a POST to your endpoint runs in the victim's authenticated context — the request looks identical to a legitimate one because the browser supplied the session cookie unprompted. SameSite cookies (especially `lax`) prevent most CSRF, but not all: top-level navigation POSTs in some browsers, subdomain confusion, and cookieless auth schemes (Bearer headers in localStorage) still need explicit CSRF protection. CWE-352 is the class.
The attack
Attacker page hosted on evil.com:
<!-- evil.com/landing-page.html -->
<form action="https://app.example.com/profile/email" method="POST" id="f">
<input name="email" value="attacker@evil.com">
</form>
<script>
document.getElementById("f").submit();
// Victim browser auto-attaches app.example cookie if sameSite isn't strict.
// Email gets changed without victim's knowledge.
</script>Next step: attacker triggers a password reset to the new email — full account takeover.
Mesrai's review comment
mesraipilot · Bot · reviewed 1 min ago
[mesrai] [code-review] [Security] [CWE-352] [high]
State-changing POST endpoint without CSRF token validation. The
auth middleware only checks the session cookie — which the browser
supplies on cross-site POSTs by default.
Two options for 2026:
1. (Recommended) sameSite:strict cookies + double-submit token
in a custom header. The header is set by your frontend JS;
attacker JS on another origin cannot set custom headers on
cross-origin requests without preflight.
2. Bearer token in Authorization header from localStorage. No
cookie means no automatic browser-attached credential —
CSRF is structurally impossible. (Trade-off: more XSS surface.)
For cookie-based sessions, add the csrfProtection middleware:
app.post("/profile/email", requireAuth, csrfProtection, async ...
And ensure the frontend reads the token from a Set-Cookie or a
meta tag and sends it back in `X-CSRF-Token`.
Reference: OWASP CSRF Prevention Cheat Sheet, CWE-352The fix
// api/profile.ts — fixed
import { csrfProtection } from "../middleware/csrf";
app.post("/profile/email", requireAuth, csrfProtection, async (req, res) => {
await db.users.update(req.user.id, { email: req.body.email });
res.json({ ok: true });
});`csrfProtection` validates a token in a custom header against a value bound to the session. Since cross-origin attacker JS cannot set custom headers without a CORS preflight (and your endpoint can reject preflights), the token cannot be replayed from another origin. Combine with `sameSite:strict` cookies for defense in depth.
Why human review missed it
CSRF used to be on every checklist a decade ago, then SameSite cookies arrived and many teams concluded the class was closed. It is not — Lax leaves top-level navigation POSTs unprotected, Strict breaks OAuth flows, and `Bearer` token APIs are a separate consideration. Mesrai's rule pack flags every state-changing endpoint without an explicit CSRF check, regardless of the cookie config.
Related rules + further reading
Mesrai rule pack: security/csrf-required-on-mutations — flags POST/PUT/PATCH/DELETE handlers without CSRF middleware.
OWASP: Cross-Site Request Forgery Prevention Cheat Sheet.
CWE-352 — Cross-Site Request Forgery.
Takeaway
SameSite cookies help. They do not close the CSRF class. Pair with a double-submit token on every state-changing endpoint.