Sessions and Tokens — From the Browser to the Mesh
Test CSRF defenses with forged requests
Goal
On the transfer server at 127.0.0.1:8303, add in turn a per-session CSRF token, a Sec-Fetch-Site check, and an Origin allowlist, and confirm yourself that a forged request is not only rejected with a 403 but also does not get recorded.
Why it matters
There is one reason CSRF works. Because the browser automatically attaches the destination's cookie, a transfer that started from an attacking page looks to the server exactly like a transfer the user pressed. So every defense works by requiring "a signal that an attacking page cannot imitate". A token bound to the session cannot be read by the attacking page, and Sec-Fetch-Site and Origin are attached by the browser and cannot be changed by the page. SameSite adds only one more layer to this. A sibling subdomain is treated as the same site, and if you change state with GET, the Lax cookie is carried as is. This lab's grader makes forged requests directly with curl and checks the transfer record together with the response code.
Steps
- Bring up
/root/st/csrf/app.pyat 127.0.0.1:8303.GET /healthzreturns 200 and{"ok":true}.POST /login(JSON{"user","password"},alice/wonderland,bob/builder) gives a__Host-sidsession cookie, andGET /transfersgives that session's transfer list as{"transfers":[{"to","amount","memo"}...]}. GET /csrfgives{"csrf":"<토큰>"}(the placeholder is the token) to a logged-in session. The token is random of at least 32 bytes (at least 43 base64url characters), must be the same no matter how many times you call it in the same session, and must differ when the session differs. The token is a synchronizer token that the server stores in the session record and is not sent down as a separate cookie. Calling it without a session is a 401.- If
POST /transfer(JSON{"to","amount","memo"}) has noX-CSRF-Tokenheader or a value the server never issued, it returns 403 and does not record the transfer. Even if an attacker plants a cookie such ascsrf=Xand also putsXin the header, it must be 403 (the header is compared not with the cookie but with the token stored in the session). A transfer must not be recorded throughGET /transfer?to=...&amount=...&memo=...(the response code does not matter). - If you send the genuine token received in bob's session together with alice's session cookie, it is a 403 and must not be recorded as alice's transfer.
- A transfer that carries your own session's token in
X-CSRF-Tokenis 200 and is recorded inGET /transfers. Do the token comparison withhmac.compare_digest. - If the
Sec-Fetch-Siteof a state-changing request (POST) iscross-siteorsame-site, it is 403 and is not recorded even when the token is right. If it issame-origin, judge by the token and let it through, and if the header is absent (an old browser or a script), judge by the token.POST /logingoes through the same check, and a cross-site login is not given a session cookie. - If there is an
Originheader, it must equal the allowlisthttp://127.0.0.1:8303as a whole string.http://evil.example,null,http://127.0.0.1:8303.evil.example, andhttp://127.0.0.1:8300are 403 and not recorded even when the token is right. A normal transfer withOrigin: http://127.0.0.1:8303is 200. - The user is logged in to
https://bank.example.com, and the session cookie was issued a few hours ago withSameSite=Laxstated explicitly. The attacking page ishttps://evil.example.netand the sibling page ishttps://blog.example.com. For the seven cases below, judge whether that cookie is carried on the request and write one line each into/root/st/csrf/samesite.txtas항목=sentor항목=not_sent(the placeholder is the case name).link_getclick a link on the evil page to go tohttps://bank.example.com/account(top-level GET)form_postan auto-submitted form on the evil page POSTs tohttps://bank.example.com/transfer(top-level navigation)img_get<img src="https://bank.example.com/transfer?to=mallory">on the evil pagefetch_postfetch("https://bank.example.com/transfer", {method:"POST", credentials:"include"})on the evil pageiframe_getthe evil page loadshttps://bank.example.com/accountas an iframesibling_form_posta form on the blog page POSTs tohttps://bank.example.com/transfer(top-level navigation)link_get_transferclick the linkhttps://bank.example.com/transfer?to=mallory&amount=100on the evil page to go there (top-level GET)
- With
/root/st/csrf/e2e.sh, send four forged transfers to alice's session with the same memo (no token, bob's token,Sec-Fetch-Site: cross-site,Origin: http://evil.example), add the number of transfers recorded under that memo and the result of one normal transfer, and writeno_token=403 other_session=403 cross_site=403 bad_origin=403 valid=200 forged_recorded=0as a single line in/root/st/csrf/e2e.out.
Notes
- Pulling out the session cookie value:
curl -s -D - -o /dev/null ... | sed -n 's/^[Ss]et-[Cc]ookie: *__Host-sid=\([^;]*\).*/\1/p' - Imitating a forged request:
curl -b "__Host-sid=<값>" -H "Sec-Fetch-Site: cross-site" -H "Origin: http://evil.example" ...(the placeholder is the value) — in a real browser the page cannot change these two headers. - The check order is origin signals (Sec-Fetch-Site, Origin), then the token, then the record. If you check after recording, the transfer remains even though you gave a 403.
- Common mistake 1: checking the token only as 'has it ever been issued' — a token that an attacker received with their own account gets through.
- Common mistake 2: comparing Origin with
startswith—http://127.0.0.1:8303.evil.examplegets through. - Apart from the result files, the step 9 grader sends the four forgeries and
GET /transferagain itself to see that they are not recorded.
Bring up the transfer server
Bring up /root/st/csrf/app.py at 127.0.0.1:8303. GET /healthz returns 200 and {"ok":true}. POST /login (JSON {"user","password"}, alice/wonderland, bob/builder) gives a __Host-sid session cookie, and GET /transfers gives that session's transfer list as {"transfers":[{"to","amount","memo"}...]}.
You can use the cookie server from the earlier module as a skeleton. If you leave room in the session record in advance for the token and the transfer list along with the user, the later steps get easier.
Issue a per-session CSRF token
GET /csrf gives {"csrf":"<토큰>"} (the placeholder is the token) to a logged-in session. The token is random of at least 32 bytes (at least 43 base64url characters), must be the same no matter how many times you call it in the same session, and must differ when the session differs. The token is a synchronizer token that the server stores in the session record and is not sent down as a separate cookie. Calling it without a session is a 401.
If you make the token when you make the session and put it inside the session record, it shares the session's fate. Make it with the secrets module, just like the session value.
Reject a transfer with no token
If POST /transfer (JSON {"to","amount","memo"}) has no X-CSRF-Token header or a value the server never issued, it returns 403 and does not record the transfer. Even if an attacker plants a cookie such as csrf=X and also puts X in the header, it must be 403 (the header is compared not with the cookie but with the token stored in the session). A transfer must not be recorded through GET /transfer?to=...&amount=...&memo=... (the response code does not matter).
A form on an attacking page carries the cookie but does not know the token. The 403 is meaningful only if you do the check before recording the transfer. A cookie can also be planted by an attacker from a sibling subdomain, so it cannot be the basis of comparison, and you do not leave state changes to a GET that can be sent with a single link.
Bind the token to the session
If you send the genuine token received in bob's session together with alice's session cookie, it is a 403 and must not be recorded as alice's transfer.
If you compare against a list of all the tokens issued, you get stuck at this step. You must compare only with the token of the session that you found with the session cookie attached to the request.
Let the correct token through
A transfer that carries your own session's token in X-CSRF-Token is 200 and is recorded in GET /transfers. Do the token comparison with hmac.compare_digest.
A defense is complete when it lets a normal request through. A string comparison operator stops at the first mismatch, so use a function whose comparison time does not vary with the value.
Block cross-site requests with Sec-Fetch-Site
If the Sec-Fetch-Site of a state-changing request (POST) is cross-site or same-site, it is 403 and is not recorded even when the token is right. If it is same-origin, judge by the token and let it through, and if the header is absent (an old browser or a script), judge by the token. POST /login goes through the same check, and a cross-site login is not given a session cookie.
The browser attaches this header and page scripts cannot change it. Look at it before the token, in the one place that every POST goes through. If you have no reason to trust sibling subdomains, treat same-site as cross as well.
Check the Origin allowlist
If there is an Origin header, it must equal the allowlist http://127.0.0.1:8303 as a whole string. http://evil.example, null, http://127.0.0.1:8303.evil.example, and http://127.0.0.1:8300 are 403 and not recorded even when the token is right. A normal transfer with Origin: http://127.0.0.1:8303 is 200.
A comparison that only checks whether the beginning matches is fooled by a domain name the attacker chose. In places such as a sandboxed iframe, Origin arrives as the string null.
Fill in the SameSite=Lax judgment table
The user is logged in to https://bank.example.com, and the session cookie was issued a few hours ago with SameSite=Lax stated explicitly. The attacking page is https://evil.example.net and the sibling page is https://blog.example.com. For the seven cases below, judge whether that cookie is carried on the request and write one line each into /root/st/csrf/samesite.txt as 항목=sent or 항목=not_sent (the placeholder is the case name). link_get click a link on the evil page to go to https://bank.example.com/account (top-level GET) · form_post an auto-submitted form on the evil page POSTs to https://bank.example.com/transfer (top-level navigation) · img_get <img src="https://bank.example.com/transfer?to=mallory"> on the evil page · fetch_post fetch("https://bank.example.com/transfer", {method:"POST", credentials:"include"}) on the evil page · iframe_get the evil page loads https://bank.example.com/account as an iframe · sibling_form_post a form on the blog page POSTs to https://bank.example.com/transfer (top-level navigation) · link_get_transfer click the link https://bank.example.com/transfer?to=mallory&amount=100 on the evil page to go there (top-level GET)
A Lax cookie is carried on a cross-site request only when two conditions hold at the same time. First sort out whether the origin and the destination are the same site. A site is wider than an origin. A line that starts with # can be left as a comment.
Prove it all at once with a batch of forged requests
With /root/st/csrf/e2e.sh, send four forged transfers to alice's session with the same memo (no token, bob's token, Sec-Fetch-Site: cross-site, Origin: http://evil.example), add the number of transfers recorded under that memo and the result of one normal transfer, and write no_token=403 other_session=403 cross_site=403 bad_origin=403 valid=200 forged_recorded=0 as a single line in /root/st/csrf/e2e.out.
If you group the requests of the earlier steps into a few functions, it gets shorter. If you put the same memo on the four forgeries, you can count them all at once by how many times that memo appears in the record list.