CSRF vulnerability: a local SameSite cookie experiment
A CSRF vulnerability lets a page on another site make a logged-in user's browser send a request that changes state, such as a new account email. The flaw exists when the endpoint trusts the session cookie alone. In a small local account service, a forged cross-site POST returned 200 and changed the email to attacker@evil.test. After we added a CSRF token, an Origin check and a SameSite=Lax cookie, every forged request was refused and the legitimate form still worked.
This is a scripted Python, curl and Chromium experiment with downloadable source and responses. No AI agent or model ran the test.
Define the CSRF vulnerability and the fixture scope
Cross-site request forgery (CSRF) makes a victim's browser send a state-changing request to a site where the victim is logged in. The browser attaches the session cookie by itself, so a server that checks only the cookie cannot tell the forged request from a real one. A page on another site can trigger it with a plain HTML form, which is why CSRF defences need a server-side rule in addition to a login check.
The SameSite cookie attribute limits when a browser sends a cookie on cross-site requests. A CSRF token is a secret per-session value that the real form carries and a forged request cannot know. The OWASP CSRF Prevention Cheat Sheet treats tokens as its main mitigation and lists SameSite under defense in depth techniques. It also says not to change state with GET requests. The MDN Set-Cookie reference documents the three SameSite values.
- Target: a toy account service on
127.0.0.1with one synthetic user, a cookie session and one endpoint that changes the account email byPOST /account/email. Vulnerable mode also changes the email onGET /account/email/change. - Attacker page: a second local server, opened in the browser as
http://localhost:PORT. It serves one auto-submitting form and one page that navigates the top-level window to the GET URL. - Two sites: MDN describes a site by its registrable domain. We used the hostnames
localhostand127.0.0.1so the browser treats the two origins as cross-site. We did not test two ports on one host. - Data: synthetic addresses and per-run random cookie values. No external network target is involved.
This test needs the system owner's permission on any real target. The Excession Labs Acceptable Use Policy describes authorized use, and this article gives no permission to test another system. The login that sets the session is also a toy: it is a GET request that needs no password. Compare the access-control side of the same kind of check in API authorization testing.
Run the downloadable fixture
Download fixture.py, verify.py and browser_test.cjs into one directory. The curl part needs Python 3.9 or later and curl. The browser part needs Node.js and the Playwright package with Chromium, which the bundle does not install. Run:
python3 verify.py --output rerun
python3 verify.py --output rerun2 --node-modules /path/to/node_modules --wait-seconds 125
The first command runs the curl cases only. The second adds Chromium and one extra case that waits 125 seconds. The output directory must be new. The runner starts the target in vulnerable mode, then in fixed mode, on free loopback ports, sends eight curl requests per mode, drives the browser, saves every raw response and stops the servers.
Our recorded run used the second form on 10 November 2026 (22:48 to 22:50 UTC), with Python 3.15.2, curl 8.7.1 and Chromium 156.0.8078.4 on macOS. It printed {"checks": 16, "passed": true, "failures": []}. The 16 checks are the curl cases. Passed means no curl or browser result differed from its expected value for that mode, including the vulnerable ones. Versions, commands and source SHA-256 hashes are in results.json.
Observed results: a forged request changes the email
Each curl case resets the account, logs in, sends one request with the session cookie, then reads the email back from the server. curl ignores SameSite, so these eight cases test the server's own defences. The forged requests carry the header Origin: http://localhost:9, the origin of a page on another site.
| Request | Vulnerable mode | Fixed mode |
|---|---|---|
| View account (control) | 200 | 200 |
| Real form POST: same Origin, valid token (control) | 200, email changed | 200, email changed |
| Forged POST: cross-site Origin, no token | 200, email changed to attacker@evil.test | 403 bad origin, email unchanged |
| Forged POST: cross-site Origin, valid token | 200, email changed | 403 bad origin |
| POST with same Origin, no token | 200, email changed | 403 bad csrf token |
| POST with same Origin, wrong token | 200, email changed | 403 bad csrf token |
| Forged POST without the cookie (control) | 401 | 401 |
| Forged GET to the change URL | 200, email changed | 405, email unchanged |
The control rows show that the fix did not break the application. A server that refuses every POST would also pass the forged rows. The no-cookie row shows that vulnerable mode still requires a session. What it lacks is a check that the request came from the application's own pages.
Curl shows that the vulnerable server has no defence of its own. Only a real browser shows whether a victim's browser sends the cookie, so we drove Chromium: it logged in at the target, then loaded the attacker page from the other hostname. The target recorded what arrived. Every forged request below had Sec-Fetch-Site: cross-site and the attacker origin as Origin or Referer.
| Cookie attribute | Cross-site form POST | Cross-site top-level GET |
|---|---|---|
| SameSite=None; Secure | sent, email changed | sent, email changed |
| SameSite=Lax | not sent, email unchanged | sent, email changed |
| SameSite=Strict | not sent, email unchanged | not sent, email unchanged |
| No attribute, cookie just set | sent, email changed | sent, email changed |
| No attribute, cookie 125 seconds old | not sent, email unchanged | not tested |
Chromium stored the cookie with no attribute as Lax. Its cross-site POST still carried the cookie when the cookie was new, and not when it was 125 seconds old. MDN describes this: for cookies without an explicit SameSite value, a more permissive Lax mode also sends the cookie on POST requests for two minutes after it was set. A cookie set explicitly as SameSite=Lax did not get that allowance in our run.
What SameSite=Lax does not cover
In Chromium, a Lax cookie was not sent on the forged POST and was sent on the forged GET. MDN says Lax sends the cookie on cross-site top-level navigations, and a link or a script that sets location.href is one. If any GET handler changes state, Lax lets an attacker use it. A Strict cookie was sent on neither. OWASP says Strict can disrupt legitimate navigation into an application, and describes Lax as the balance between security and usability.
SameSite also depends on the browser. We tested one Chromium build. The OWASP cheat sheet advises treating SameSite as an extra layer and keeping a token or an Origin check, and it lists the GET gap among the known limits. For another session-cookie weakness, see session fixation.
Verify the CSRF finding before you report it
A 200 status is weak evidence. Check these facts, each from a captured response:
- The server received a request whose
Sec-Fetch-Sitewascross-siteand whoseOriginorReferernames another site. - The session cookie was present on that request. A browser log or a server log shows this. For Lax and Strict cookies, check the request type as well.
- The state changed and a later read shows it. Here the read is
GET /_state, which returns the email. - A control request with a valid token and the real Origin succeeds, and a request without the cookie returns 401.
In the recorded vulnerable run the forged POST used this command against port 54259. The recorded response is a 200 with {"email": "attacker@evil.test"}.
curl --noproxy '*' --silent --show-error --include --http1.1 \
--connect-timeout 1 --max-time 3 --request POST \
--header 'Cookie: sid=<session value>' \
--header 'Origin: http://localhost:9' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data email=attacker@evil.test \
http://127.0.0.1:54259/account/email
Exploitability depends on the cookie. A curl success against a SameSite=Lax cookie on a POST-only endpoint proves the missing token, and the browser run shows that a cross-site POST would not carry that cookie. A finding should say which of those facts it demonstrates. This fixture does not rate severity: the impact here is one changed synthetic email address.
Fix and retest
Fixed mode uses three changes, all in fixture.py. The session cookie gets SameSite=Lax and HttpOnly. The change-email POST checks the Origin header against the host and compares a per-session token. The GET route refuses to change state.
return sid, f"sid={sid}; Path=/; HttpOnly; SameSite=Lax"
if self.server.mode == "fixed":
expected_origin = f"http://{self.headers.get('Host')}"
if self.headers.get("Origin") != expected_origin: # FIX 1: Origin check
return self.reply(403, {"error": "bad origin"})
sent = form.get("csrf_token", [""])[0]
if not hmac.compare_digest(sent, sess["csrf"]): # FIX 2: CSRF token
return self.reply(403, {"error": "bad csrf token"})
Retest results are in the table above. Both forged POSTs returned 403, the same-origin requests without a valid token returned 403, and the forged GET returned 405 with an Allow: POST header. The email stayed at alice@example.test in all of them. The real form returned 200 and changed the email.
The browser retest agreed. Against fixed mode the forged form POST arrived without the cookie and the email stayed unchanged. The forged top-level GET did carry the Lax cookie, as expected, and the 405 left the email unchanged. A real form submitted from the target's own page sent Sec-Fetch-Site: same-origin and changed the email to alice-new@example.test. In the curl rows, a valid token with a bad Origin was refused, and the right Origin with no token was refused. Each check works on its own, without help from the cookie attribute.
Inspect the evidence bundle
- Machine-readable results: 16 curl cases and 12 browser cases with expected and observed values, headers the server saw, and response hashes.
- Exact curl commands. The ports and cookie values belong to the finished run.
- SHA-256 manifest for the source and recorded files. It checks integrity and is not independent attestation.
- Instructions, fixture.py, verify.py and browser_test.cjs.
What this experiment does not cover
This is one toy endpoint tested with curl and one Chromium build. We did not test Firefox or Safari, which may differ. We did not test JSON or multipart bodies, fetch with CORS, login CSRF, sibling-subdomain cookies, cookie prefixes, iframes or subresource GETs. The Origin check rejects a request with no Origin header and trusts the Host header, which production code should compare to a configured value. Browsers send Origin on cross-origin POST requests, but other clients may not.
A real test uses a second account and agreed scope, and records the cookie attributes the application sets. The fixed fixture shows that these three controls work together on this endpoint. It says nothing about another application. Related cross-site flaws are covered in open redirect.
Excession Labs runs this kind of check on state-changing endpoints in its security assessments. See how a finding like this looks in a sample report.