3 — Forged requests
Chapter 2 left the session cookie as tight as cookie attributes allow: __Host-sid, HttpOnly, Secure, SameSite=Lax, rotated on login. It also left two gaps visible in its SameSite table. Another site can still navigate alice’s browser to the app with her cookie attached, and a sibling subdomain like pages.notes.test counts as the same site, so SameSite does nothing about it at all.
Chapter 1 showed the other half: any page can make the browser send a request to the app, even though it can’t read the answer. Put a cookie on that request, and the app can’t tell it from one alice made herself. That’s cross-site request forgery, CSRF. The attacker never sees the response and doesn’t need to. The damage is done when the server acts.
The forgery that worked on version one
The prize is alice’s “Door code” note, number 1. The app lets an owner share a note with another user, and a share is just a form:
<form method="post" action="/notes/1/share">
<input name="with"><button>Share</button>
</form>
So a page on attacker.test builds the same form, fills in mallory’s name, and submits it as soon as it loads:
<form method="POST" action="http://notes.test:5101/notes/1/share">
<input name="with" value="mallory">
</form>
<script>document.forms[0].submit()</script>
Against version one, over plain HTTP, with alice logged in, the app logged:
POST notes.test:5101/notes/1/share Origin=http://attacker.test:5102 Cookie=sid=1trkBw55...
SHARED note 1 of alice with mallory
Mallory now sees “Door code” in her own list. Alice saw her browser land on her own note page and nothing else; a real attacker would submit the form into a hidden iframe or open it in a pop-under and close it.
That run fell inside the two-minute window from chapter 1, during which Chrome still sends a cookie with no SameSite attribute on a cross-site POST. After the window it fails. In browsers that don’t default to Lax, or for any app that sets SameSite=None on its session (common for apps that need to work inside iframes on other sites), it works every time.
What Lax stops
Against the app as chapter 2 left it, now at https://notes.test:5101, the identical form from https://attacker.test:5104 gets nowhere:
POST https://notes.test:5101/notes/1/share [Document] status=401
cookies withheld: __Host-sid (SchemefulSameSiteLax)
The request arrived without a session, so the app treated it as anonymous and refused. Every cross-site POST, whether a form in a frame or at the top level, and every cross-site <img>, <iframe> or fetch is stopped the same way. That covers the classic form of CSRF from an unrelated site, which is why Lax by default changed the picture so much when it shipped.
What Lax doesn’t stop
A GET that changes something
The share route was written as
@app.route("/notes/<int:nid>/share", methods=["GET", "POST"])
def share_note(nid):
...
other = db().execute("SELECT id FROM users WHERE username=%s", (request.values["with"],)).fetchone()
request.values merges the query string and the form body, so the route happily takes ?with=mallory on a GET. Lax sends the cookie on cross-site top-level GET navigations, so the attacker page only has to navigate:
location.href = "https://notes.test:5101/notes/1/share?with=mallory";
No click was needed; script-initiated navigation counts. The result:
GET notes.test:5101/notes/1/share?with=mallory Origin=None Sec-Fetch-Site=cross-site Cookie=__Host-sid=eC-ELPhO...
SHARED note 1 of alice with mallory
Note Origin=None. Chrome doesn’t send an Origin header on GET navigations, so a server checking Origin wouldn’t have seen anything to check.
HTTP has always said GET should be safe, meaning it doesn’t change state on the server. That’s not etiquette. Browsers, caches, link prefetchers and SameSite=Lax are all built on it.
A sibling subdomain
Same-site requests carry every cookie, Strict ones included. The same share form, served from https://pages.notes.test:5104:
POST notes.test:5101/notes/1/share Origin=https://pages.notes.test:5104 Sec-Fetch-Site=same-site Cookie=__Host-sid=7OzSBPAp...
SHARED note 1 of alice with mallory
There are plenty of ways an attacker ends up controlling a page on a sibling subdomain. The company may host user content there, the way many services put uploads or user pages on a subdomain. An old marketing site may run outdated software. A DNS record may point at a cloud resource that has since been released, letting anyone claim it (chapter 11 covers that takeover). Any XSS on any subdomain also qualifies. SameSite takes the view that a site is one trust domain, and if that isn’t true for the app’s domain, SameSite doesn’t protect it.
A JSON API, through a form
The JSON endpoint POST /api/notes looks harder to forge. HTML forms can’t send application/json, and a fetch with that content type from another origin needs a CORS preflight, which the app never answers. That part held up. From the sibling:
fetch("https://notes.test:5101/api/notes", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "via fetch", body: "x" }),
})
OPTIONS https://notes.test:5101/api/notes [Other] status=200
POST https://notes.test:5101/api/notes [Fetch] FAILED: CORS:PreflightMissingAllowOriginHeader
The browser sent only the OPTIONS preflight, saw no Access-Control-Allow-Origin in the answer, and never sent the POST. The app’s log shows the OPTIONS and nothing after it.
But the API reads its input like this:
data = json.loads(request.get_data())
It parses the body as JSON whatever the Content-Type says. And a form can send a body that is valid JSON, using enctype="text/plain", which joins each field as name=value:
<form method="POST" action="https://notes.test:5101/api/notes" enctype="text/plain">
<input name='{"title":"planted by pages.notes.test","body":"' value='"}'>
</form>
The body comes out as {"title":"planted by pages.notes.test","body":"="}. Forms can only send three content types, application/x-www-form-urlencoded, multipart/form-data and text/plain, and CORS lets all three through without a preflight, because forms could always send them. From the sibling, the app logged:
POST notes.test:5101/api/notes Origin=https://pages.notes.test:5104 Sec-Fetch-Site=same-site Cookie=__Host-sid=1YGt8ohZ...
API created note 3 for alice: 'planted by pages.notes.test'
Defenses
CSRF defenses all come down to one question: did this request come from the app’s own page? There are three ways to ask it. The app can require something only its own pages know (a token). It can ask the browser where the request came from (Origin or Fetch Metadata). Or it can make sure the request is one a foreign page can’t construct at all (custom headers, strict content types). They layer well, and the app ends up using several.
First: GET is safe
The share and logout routes lose their GET:
@app.route("/notes/<int:nid>/share", methods=["POST"])
and the forged navigation now gets a 405:
GET https://notes.test:5101/notes/1/share?with=mallory [Document] status=405
cookies sent: __Host-sid
The cookie still went, as Lax allows. It no longer matters, because the route won’t act on a GET. Every route that changes state needs this, and it’s easier to audit as a rule (“no state changes on GET, ever”) than route by route.
The synchronizer token
The oldest robust defense is a secret value tied to the session that the app embeds in its own forms and checks on every unsafe request. Each sessions row already has a csrf column, generated with the session ID and rotated with it at login. Every form in the app’s templates gets a hidden field:
def csrf_field():
return f'<input type="hidden" name="csrf_token" value="{g.sess["csrf"]}">'
<form method="post" action="/notes/1/share"><input type="hidden" name="csrf_token" value="JgdQt9D6Y3IYtPk2zXOY1ehOIOaW7a8M7vKYBNThrdc"><input name="with"><button>Share</button></form>
and a check runs before every non-GET request:
if request.method not in ("GET", "HEAD", "OPTIONS"):
sent = request.form.get("csrf_token", "")
if not g.sess or not secrets.compare_digest(sent, g.sess["csrf"]):
abort(403)
compare_digest compares in constant time, so the response time doesn’t reveal how many leading characters were right.
The attacker’s form doesn’t contain the token, because the attacker doesn’t know it. The sibling’s forged share:
POST https://notes.test:5101/notes/1/share [Document] status=403
cookies sent: __Host-sid
blocked: bad CSRF token
Why can’t the sibling just fetch a page from the app and copy the token out? Because that’s a read, and reads are what the same-origin policy blocks. The sibling tried:
fetch("https://notes.test:5101/", { credentials: "include" }).then(r => r.text())
GET https://notes.test:5101/ [Fetch] status=200
cookies sent: __Host-sid
FAILED: net::ERR_FAILED CORS:MissingAllowOriginHeader
The request went with alice’s cookie, the app served her page with her token in it, and Chrome kept the response from the script. CSRF defense rests on that: the attacker can make the browser send anything but can’t read what comes back. If an XSS hole lets script run inside the app’s origin, it reads the token like any other page content, which is why no CSRF defense survives chapter 4’s attacks.
Some details matter. The token has to be unpredictable (here 256 bits from secrets) and specific to the session, not a global value. It must not go in URLs, where it leaks into logs and Referer headers. And it rotates with the session at login, so a token seen before login is worthless after it. For JavaScript clients, the same token usually travels in a header such as X-CSRF-Token, read from a <meta> tag in the page.
The double-submit cookie, and why the naive version fails
The synchronizer token needs server-side state. The double-submit pattern avoids that: the server sets the token as a cookie and also puts it in the form, and on each POST checks that the two match. An attacker on another site can’t read the cookie, so the reasoning goes, and can’t put the right value in the form.
The app tried the naive version:
cookie, sent = request.cookies.get("csrf", ""), request.form.get("csrf_token", "")
if not cookie or not secrets.compare_digest(cookie, sent):
abort(403)
The flaw is in “can’t read the cookie”. The attacker doesn’t need to read it; it’s enough to write one. Chapter 2 showed a sibling subdomain can set cookies for the parent domain, and with a longer path its cookie comes first. So pages.notes.test planted a value of its own choosing and submitted a form carrying the same value:
Set-Cookie: csrf=chosen-by-mallory; Domain=notes.test; Path=/notes; Secure
<form method="POST" action="https://notes.test:5101/notes/1/share">
<input name="with" value="mallory">
<input name="csrf_token" value="chosen-by-mallory">
</form>
POST notes.test:5101/notes/1/share Origin=https://pages.notes.test:5104 Sec-Fetch-Site=same-site
Cookie=csrf=chosen-by-mallory; csrf=TQ4I1sJ3xAQHw...; __Host-sid=nyTEt6aW...
SHARED note 1 of alice with mallory
The browser sent both csrf cookies, the planted one first, and the app compared the form value with the first one. OWASP’s CSRF prevention recommendations mark this naive pattern as discouraged for exactly this reason. A network attacker who can answer any plain-HTTP request to the domain can plant an unprefixed cookie the same way.
The signed double-submit variant fixes it by tying the token to the session with a key only the server has:
rnd = secrets.token_urlsafe(16)
token = rnd + "." + hmac.new(SECRET, f"{g.sid}.{rnd}".encode(), hashlib.sha256).hexdigest()
# set as cookie __Host-csrf and as the hidden form field
rnd, _, mac = tok.partition(".")
good = hmac.new(SECRET, f"{g.sid}.{rnd}".encode(), hashlib.sha256).hexdigest()
if tok != request.cookies.get("__Host-csrf") or not hmac.compare_digest(mac, good):
abort(403)
A planted token fails the HMAC check, because the attacker doesn’t have SECRET and the MAC covers alice’s session ID. The __Host- prefix on the cookie stops the planting in the first place. The same attack:
POST https://notes.test:5101/notes/1/share [Document] status=403
blocked: signed double-submit check failed
The app keeps the synchronizer token, since it already has server-side sessions; the signed variant is the choice for stateless servers.
Strict content types for the API
The JSON endpoint gets one line:
if request.mimetype != "application/json":
abort(415)
Now the text/plain form is refused, and a real application/json request from another origin needs a preflight the app doesn’t grant:
POST https://notes.test:5101/api/notes [Document] status=415
rejected Content-Type text/plain
This holds only as long as CORS stays closed. Chapter 6 shows what happens when it’s opened carelessly. A required custom header (X-Requested-With, or a CSRF header) works the same way, and the token can travel in it.
Fetch Metadata
The browser knows exactly where every request came from, and since Chrome 76 in 2019 it tells the server, in the Sec-Fetch-* headers. Chapter 1 used Sec-Fetch-Site to see how Chrome classifies sites. Here it’s a defense:
if request.method not in ("GET", "HEAD", "OPTIONS"):
site = request.headers.get("Sec-Fetch-Site")
if site not in (None, "same-origin", "none"):
abort(403)
same-origin is the app’s own pages. none means the user started the request directly, from the address bar or a bookmark. Anything else, same-site or cross-site, is refused for unsafe methods. The policy web.dev suggests also allows same-site; this app refuses it because its sibling subdomain isn’t trusted. Unlike SameSite, this distinguishes same-site from same-origin, so it catches the sibling:
POST notes.test:5101/notes/1/share Origin=https://pages.notes.test:5104 Sec-Fetch-Site=same-site Cookie=__Host-sid=VU6hL0Nq...
blocked by Fetch Metadata: Sec-Fetch-Site=same-site
POST notes.test:5101/api/notes Origin=https://pages.notes.test:5104 Sec-Fetch-Site=same-site Cookie=__Host-sid=iZH38hnj...
blocked by Fetch Metadata: Sec-Fetch-Site=same-site
It also blocks the text/plain JSON trick and every cross-site form, with no change to templates.
Three limits are worth knowing. First, it leaves GET alone, so it didn’t stop the forged GET share; the GET-is-safe rule has to hold anyway:
GET notes.test:5101/notes/1/share?with=mallory Sec-Fetch-Site=cross-site Cookie=__Host-sid=rNwApQ1A...
SHARED note 1 of alice with mallory
(That run had Fetch Metadata on and the GET route still open.) Second, the header only goes to potentially trustworthy origins. Chapter 1’s plain-HTTP requests didn’t carry it, so this check is for HTTPS sites. Third, a browser too old to send it sends nothing, which is why the code lets None through: a missing header means “unknown”, and tools like curl aren’t a CSRF risk because they don’t carry alice’s cookies. OWASP’s CSRF recommendations now name Fetch Metadata as a primary defense, with an Origin check as the fallback for browsers that don’t send it. If the app ever needs a cross-site POST, such as a payment provider posting back, that one endpoint gets an explicit exception.
An Origin header check works on the same principle: on a POST, Chrome sent Origin: https://pages.notes.test:5104, and the app could compare it against its own origin. Origin is sent on every cross-origin POST in current browsers, but the scheme gets fiddly at the edges (Origin: null from sandboxed frames and some redirects must be treated as foreign), and Fetch Metadata says the same thing more directly.
Login CSRF
One more forged request needs no session at all. The attacker logs alice’s browser into mallory’s account:
<form method="POST" action="https://notes.test:5101/login">
<input name="username" value="mallory">
<input name="password" value="mallory-pw">
</form>
<script>document.forms[0].submit()</script>
This is a top-level cross-site POST, so Lax withheld alice’s cookie, but that doesn’t matter: the login doesn’t need one. It creates a new session and sets it, and Chrome accepted a Lax cookie set by the response to a top-level navigation:
POST notes.test:5101/login Origin=https://attacker.test:5104 Sec-Fetch-Site=cross-site Cookie=None
login mallory: new session yaKdvsyy…
Alice’s tab now said “Signed in as mallory”. Small username text is easy to miss. She then used the app as usual and saved a note through the normal form, which landed in mallory’s account:
notes=# SELECT n.id, u.username AS owner, n.title, n.body FROM notes n JOIN users u ON u.id = n.owner_id ORDER BY n.id;
id | owner | title | body
----+---------+----------------+-----------------------------
1 | alice | Door code | The side door code is 4417.
2 | mallory | mallory's note | nothing here
3 | mallory | Bank PIN | 2468
(3 rows)
Login CSRF is how search history, saved payment methods or uploaded documents end up in an attacker’s account. The defenses are the same ones as before, applied to the login form. With the synchronizer token on, the login form carries the token of the anonymous session the app gave alice’s browser, which the attacker can’t know (and the cross-site POST didn’t even carry that session’s cookie):
POST notes.test:5101/login Origin=https://attacker.test:5104 Sec-Fetch-Site=cross-site Cookie=None
blocked: bad CSRF token
With Fetch Metadata on, it fails earlier:
blocked by Fetch Metadata: Sec-Fetch-Site=cross-site
This is one place where version one’s habit of handing every visitor a session pays off: there is a pre-login session to hang the token on. Rotation at login then replaces both the session and its token.
All of it together
The app’s final state for this chapter runs everything at once: chapter 2’s cookie and rotation, POST-only state changes, the synchronizer token, the JSON content-type check, and Fetch Metadata. Each attack, rerun in a fresh browser against a fresh database:
| Attack | Source | Before | After |
|---|---|---|---|
| Share form POST | attacker.test | Shared (v1, fresh cookie) | 403, Fetch Metadata; cookie also withheld by Lax |
| Share via GET navigation | attacker.test | Shared | 405, POST only |
| Share form POST | pages.notes.test | Shared | 403, Fetch Metadata |
text/plain JSON to the API | pages.notes.test | Note created | 403, Fetch Metadata (415 without it) |
| Planted double-submit cookie | pages.notes.test | Shared (naive double-submit) | 403 |
| Login as mallory | attacker.test | Logged in as mallory | 403, Fetch Metadata (token without it) |
Most rows are stopped by more than one layer. That’s intended. Fetch Metadata depends on the browser, the token depends on the templates including it everywhere, SameSite depends on the threat being cross-site rather than same-site, and POST-only depends on nobody adding a GET route that writes. Each covers the others’ gaps.
Where this leaves things
Forged requests are closed. Nothing on another site, or on a sibling subdomain, can make alice’s browser share, create or log in on her behalf: state never changes on GET, every unsafe request needs a token only the app’s pages contain, the browser’s own account of where a request came from is checked, and the JSON API accepts only JSON.
All of that rests on one assumption: only the app’s own code runs on the app’s origin. The token works because the attacker can’t read the app’s pages. A script running in those pages can. And the app puts what users type straight into its pages. A note saved with the body Remember <b>this</b> part. comes back with a real <b> element in the DOM:
document.querySelector("div").innerHTML // "Remember <b>this</b> part."
document.querySelectorAll("div b").length // 1
The search page does the same with its query. /search?q=<i>door</i> renders a heading whose HTML is
Results for <i>door</i>
The note body and the query reach the browser as markup, not text. If a <b> tag gets through, so does any other tag. Chapter 4 puts a script in those places, and shows what it can do with a session it can’t read.