The Hacker heard about CSRF and added a secret token checked on the POST that changes the email. But he forgot that the same change is also available through an old GET request where the token is never checked. The leader is still always logged in.
The same Ravens control panel, but the developer added a CSRF token on the POST, which you cannot read from your site. A legacy route still changes the email over GET with no token check. The session cookie has no SameSite, so it is always attached. You pick how the forged request is delivered from the attacker page: an auto-submitting form (POST), an image that loads a URL (GET), or a link the leader opens himself (GET). The app shows whether the browser attached the session cookie, and why.
Change the leader’s recovery email to yours despite the token on the POST request.
A forged GET request changed the leader’s email, bypassing the token check that was only on POST.
Why the solution worked
The protection was incomplete: the token was checked only on POST, while the same action was reachable through GET with no check. The attacker simply takes the unprotected path. The rule: every state-changing action must use POST (or another unsafe method) and must verify the CSRF token on every state-changing route without exception; only read-only actions belong on GET. Add SameSite to the cookie too.
The root cause
The protection covered one verb (POST) and left a legacy route changing state over GET with no token; state-changing actions should never be reachable over GET at all.
How the code is fixed
A common gap: protection on one route and forgotten on another that does the same thing. Review every route that changes state, not just one.
Vulnerable code
app.post('/settings', requireToken, (req, res) => { /* protected */ });
// Left over from an older version, and it changes state with no token check:
app.get('/settings', (req, res) => {
if (!req.session.user) return res.status(401).end();
db.setRecoveryEmail(req.session.user, req.query.email);
res.end('updated');
});
Safe code
// State changes are POST only, and the token is checked everywhere.
app.post('/settings', requireToken, (req, res) => {
db.setRecoveryEmail(req.session.user, req.body.email);
res.end('updated');
});
// GET /settings only READS; it never changes anything.
app.get('/settings', (req, res) => res.json({ email: db.recoveryEmail(req.session.user) }));
Impact in the real world
CSRF often returns through a legacy or alternate route the developer forgot to protect, letting the attacker bypass the token entirely.
How defenders detect it
Defenders enforce the token on every state-changing action, forbid GET from changing anything, and audit for unprotected legacy routes.