2 — Cookies and sessions
Chapter 1 ended on the session cookie. The browser keeps other origins from reading the app’s responses, but cookies follow looser rules than origins do, and the app’s cookie went along on requests from a sibling subdomain, from another port, on links from any site, and for two minutes even on cross-site form posts. Every one of those was a browser default, because the cookie said nothing about itself:
Set-Cookie: sid=nuNzQHRmu5dYzJsi0Wia9xNG67kMSlQomVDMwm0Zb28; Path=/
This chapter is about that one line. Whoever has the value in it is alice, as far as the app is concerned, so it matters who can read it, where the browser sends it, and who can write it.
What the session is
The session ID is 32 random bytes from Python’s secrets.token_urlsafe(32), 256 bits, and the row it names lives in PostgreSQL:
notes=# \d sessions
Table "public.sessions"
Column | Type | Collation | Nullable | Default
------------+--------------------------+-----------+----------+---------
sid | text | | not null |
user_id | integer | | |
csrf | text | | not null |
created_at | timestamp with time zone | | not null | now()
Indexes:
"sessions_pkey" PRIMARY KEY, btree (sid)
Foreign-key constraints:
"sessions_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id)
Nothing about alice is stored in the cookie itself; the cookie is a bearer reference to a server-side row. That makes guessing hopeless and makes revocation easy (delete the row), and it puts all the weight on keeping the ID out of the wrong hands and making sure the ID in the browser was created for the person using it.
The lab moves to HTTPS
Two of the attributes in this chapter only make sense over HTTPS, so from here the app runs with TLS at https://notes.test:5101, using a self-signed certificate for notes.test and *.notes.test; Chrome runs with --ignore-certificate-errors so it accepts it. The attacker server listens with the same certificate on port 5104 as both https://attacker.test:5104 and https://pages.notes.test:5104, and still serves plain HTTP on 5102. Version one of the app is unchanged; its cookie still carries no attributes.
Three problems with the cookie
Any script on the page can read it
After alice logs in, a script running in the app’s own page can simply ask:
document.cookie
// "sid=ybg_Q-vSgnnVmQyncns9949_9zYKqIZTy8kYCqSkE9I"
On its own that’s harmless; the app’s scripts are the app’s. But a script that gets into the page some other way, the subject of chapter 4, can read this string and send it anywhere. From then on, the attacker has the session from their own machine, for as long as the session lives.
It travels over plain HTTP
The browser then visited http://notes.test:5102/blank.html, a plain-HTTP server on the same host name. The request carried the cookie:
GET http://notes.test:5102/blank.html [Document] status=200
cookies sent: sid
and the server on port 5102 logged it:
[notes.test:5102] GET /blank.html Cookie=sid=ybg_Q-vSgnnVmQyncns9949_9zYKqIZTy8kYCqSkE9I
The cookie was set over HTTPS, but nothing told the browser to keep it there. Any plain-HTTP request to notes.test, on any port, carries it in clear text. Someone on the same Wi-Fi who can inject a single http://notes.test/ image into any page alice views gets her session out of the resulting request. HSTS (chapter 7) makes the browser refuse plain HTTP to the host altogether, but the cookie attribute is the direct fix.
Someone else can choose it
The third problem is the subtle one. In version one, the app hands every visitor a session before they log in, and the login route attaches the user to whatever session the browser already holds:
db().execute("UPDATE sessions SET user_id=%s WHERE sid=%s", (u["id"], g.sid))
If an attacker can put a session ID of their choosing into alice’s browser before she logs in, her login will upgrade the attacker’s session. This is session fixation.
Getting a cookie into alice’s browser is the hard part, and chapter 1 already showed the way: pages.notes.test is same-site with the app. A page on a subdomain is allowed to set a cookie for its parent domain with the Domain attribute, and that cookie is then sent to every host under it, including notes.test.
The attack, run for real:
-
Mallory asks the app for a page and keeps the anonymous session it gives her:
curl -sk --resolve notes.test:5101:127.0.0.1 -D - -o /dev/null https://notes.test:5101/ # Set-Cookie: sid=jMjCb-PfBG8bmNFqLyp4-bYjWQWyWN6S82CXJ8v6Pec; Path=/ -
Alice visits a page on
pages.notes.test. Its response sets mallory’s ID for the whole domain:Set-Cookie: sid=jMjCb-PfBG8bmNFqLyp4-bYjWQWyWN6S82CXJ8v6Pec; Domain=notes.test; Path=/Chrome’s cookie jar afterward:
sid=jMjCb-Pf domain=.notes.test path=/. The leading dot marks a domain cookie. -
Alice goes to the app and logs in normally. The login POST carries the planted cookie, and the app logs:
login alice: session jMjCb-Pf… now belongs to themHer page says “Signed in as alice”. Nothing looks wrong.
-
Mallory uses the ID she chose:
curl -sk --resolve notes.test:5101:127.0.0.1 -H "Cookie: sid=jMjCb-Pf..." https://notes.test:5101/api/notes[{"body":"The side door code is 4417.","id":1,"owner":"alice","title":"Door code"}]
That run had a fresh browser, so the planted cookie was the only sid it had. A second run started from a browser that had already visited the app and held its own host-only sid. The attacker page then set its cookie with a narrower path:
Set-Cookie: sid=vjOE-7wq...; Domain=notes.test; Path=/login
Now the jar had two cookies with the same name, and the login POST sent both:
POST notes.test:5101/login Cookie=sid=vjOE-7wqsOA9...; sid=_dI2Lm4dAlpqJ...
login alice: session vjOE-7wq… now belongs to them
RFC 6265 tells browsers to put cookies with longer paths first, and Flask, like most frameworks, takes the first value of a repeated name. The planted cookie only matched /login, so it won exactly on the request that mattered. On every other page alice’s own anonymous cookie was the only sid, so she appeared logged out after logging in, which might make her try again. Mallory’s curl command still returned the door code.
Any one of these three problems could be serious. Together they mean the session can be read by script, read off the network, and written by a neighbor.
The attributes
Each attribute on Set-Cookie answers one of those problems. The current cookie specification is RFC 6265; its revision, RFC 6265bis, is still an IETF draft but describes what browsers actually do, including SameSite and the prefixes.
HttpOnly hides the cookie from document.cookie and other script APIs. The browser still sends it on requests. It stops a script from stealing the session; it doesn’t stop a script running in the page from using the session, because that script’s requests carry the cookie like any other. Chapter 4 shows what that leaves.
Secure tells the browser to send the cookie only over HTTPS (or to localhost). Chrome also refuses to let a plain-HTTP page set a Secure cookie, or overwrite a Secure cookie that already exists with a non-secure one of the same name. Both showed up when http://notes.test:5102 tried, after the app had set a Secure cookie called pref:
Set-Cookie: x=1; Secure; Path=/ -> rejected: SecureOnly
Set-Cookie: pref=from-http; Path=/ -> rejected: OverwriteSecure
Domain widens the cookie. With no Domain, a cookie is host-only: sent only to the exact host that set it, notes.test and not pages.notes.test. With Domain=notes.test, it goes to every subdomain as well. There is no way to make a cookie narrower than host-only, so leaving Domain out is the safe default. A host can only name its own domain or a parent of it, and never a public suffix. A page on https://shop.example.co.uk:5104 (mapped to 127.0.0.1 like the other lab names) could set Domain=example.co.uk, but Domain=co.uk came back rejected: InvalidDomain. Without the Public Suffix List, one shop could set cookies for every .co.uk site.
Path limits which URL paths the cookie is sent to. It isn’t a security boundary. RFC 6265 warns that it “cannot be relied upon for security”, and a quick test shows why. A cookie was set with Path=/lab/ping. On the app’s home page, document.cookie didn’t include it. A same-origin iframe pointed at /lab/ping did:
document.cookie // "strict=1; lax=1; none=1; unset=1"
iframe.contentDocument.cookie // at /lab/ping: "pathonly=secret; strict=1; lax=1; none=1; unset=1"
Pages on the same origin can read each other, so a path restriction only controls where the cookie is sent, as the Path=/login attack above used.
SameSite controls whether the cookie goes on requests that come from another site. It has its own section below.
Max-Age and Expires decide whether the cookie outlives the browser session. The app sets neither, so sid is a session cookie that is dropped when the browser closes (though browsers that restore sessions on restart may keep it). How long a server-side session stays valid is a separate decision, made on the server.
The prefixes
Attributes say what a cookie should do, but the browser has no memory of who set a cookie or with what attributes. A cookie arriving at the server is just name=value. The server can’t tell whether sid came from its own Set-Cookie or from pages.notes.test, or from a plain-HTTP page.
Cookie prefixes fix that by putting the rules into the name, where the browser enforces them at set time:
- A cookie whose name starts with
__Secure-is accepted only if it hasSecureand was set from an HTTPS page. - A cookie whose name starts with
__Host-is accepted only if it hasSecure, was set from an HTTPS page, hasPath=/, and has noDomainattribute.
So a cookie named __Host-sid can only be host-only, HTTPS-only and site-wide. If the server receives __Host-sid, it was set by notes.test itself over HTTPS. No subdomain and no plain-HTTP page can write one.
Chrome enforced these when the plain-HTTP server on http://notes.test:5102 tried:
Set-Cookie: __Host-sid=FROMHTTP; Path=/ -> rejected: InvalidPrefix
Set-Cookie: __Secure-x=1 -> rejected: InvalidPrefix
The fix
The app’s changes for this chapter are small. The cookie gets a new name and all the attributes:
COOKIE = "__Host-sid"
def set_session_cookie(resp, sid):
resp.set_cookie(COOKIE, sid, httponly=True, secure=True, samesite="Lax", path="/")
Set-Cookie: __Host-sid=YRa0jP9EgqsRMyO32ksgfuj8JzojyemMI5ou-0qqrMA; Secure; HttpOnly; Path=/; SameSite=Lax
And login no longer upgrades the session the browser brought. It throws it away and issues a new one:
if g.sid:
db().execute("DELETE FROM sessions WHERE sid=%s", (g.sid,))
sid = new_session(u["id"])
set_session_cookie(resp, sid)
This is session rotation. Either half would have stopped the fixation run above; together they mean an attacker needs to plant a cookie the browser won’t accept, and even if they managed it, the planted ID would be deleted the moment it became valuable. The same rotation belongs anywhere a session’s privilege changes: on login, when a user re-enters a password to reach a sensitive page, on any switch into an admin role.
Running the attacks again
Mallory fetched an anonymous ID as before. Then the browser visited pages.notes.test, which tried three ways to plant it:
Set-Cookie: __Host-sid=YRa0...; Secure; Path=/; Domain=notes.test -> rejected: InvalidPrefix
Set-Cookie: __Host-sid=YRa0...; Secure; Path=/ -> accepted, host-only for pages.notes.test
Set-Cookie: sid=YRa0...; Domain=notes.test; Path=/ -> accepted for .notes.test
The first is the attack the prefix exists for, and Chrome refused it. The second was accepted, but as a cookie for pages.notes.test only; on the next request to the app it showed up as __Host-sid (DomainMismatch), withheld. The third was accepted and sent to the app, which doesn’t read a cookie called sid anymore.
Then alice logged in. Her browser held a fresh anonymous __Host-sid beginning Uv31gyrd before the login, and one beginning FbO_VC5I after it. Replaying the old one:
pre-login sid replayed after login: 401
mallory replays her planted sid: 401
alice's current sid: 200
On the app’s page, document.cookie now returned "sid=gEjZBZS2...": only the junk cookie the sibling had planted. The real session cookie is HttpOnly and invisible to script.
The plain-HTTP visit to http://notes.test:5102 went out without it:
GET http://notes.test:5102/blank.html
cookies withheld: __Host-sid (SecureOnly)
Logging out
Logging out has to end the session on the server, not just in the browser. Version one set user_id back to NULL, which happens to work, but a common mistake is to only delete the cookie, leaving the ID valid for anyone who copied it. The fixed app deletes the row and clears the cookie:
db().execute("DELETE FROM sessions WHERE sid=%s", (g.sid,))
resp.delete_cookie("__Host-sid", path="/", secure=True, samesite="Lax")
The delete_cookie call is itself a Set-Cookie with an expiry in the past, and it has to carry Secure and Path=/ to satisfy the prefix rules, or the browser would reject the deletion. After alice clicked “Log out”, the browser’s jar held a different __Host-sid: the redirect to the home page handed her a new anonymous session, as version one does for every visitor. The ID she had been using was replayed with curl:
old sid after logout: 401
The app’s logout button submits a POST, but the route still accepts GET too, left over from version one. A logout that works over GET can be triggered by any <img> tag on a page whose requests carry the cookie. Chapter 3 closes that along with the other GET routes that change things.
Server-side expiry belongs here too, though this app doesn’t implement it: an idle timeout (no request for, say, 30 minutes) and an absolute one (a session older than a day ends no matter what), both checked against timestamps in the sessions row. OWASP’s session management recommendations discuss the trade-offs.
SameSite, measured
SameSite decides whether a cookie is attached to a request that a page on another site started. It has three values:
Strict: never on cross-site requests, not even when the user clicks a link.Lax: not on cross-site subresource requests (images, frames,fetch) or cross-site POSTs, but yes on top-level navigations with a safe method, which in practice means following a link.None: on every request, same-site or not. Chrome requiresSecurewith it.
A cookie with no SameSite attribute is treated as Lax in Chrome, with the two-minute POST exception from chapter 1.
The app set five test cookies on https://notes.test:5101:
strict=1; SameSite=Strict; Secure; Path=/
lax=1; SameSite=Lax; Secure; Path=/
none=1; SameSite=None; Secure; Path=/
unset=1; Secure; Path=/
nonesecure=1; SameSite=None; Path=/
The last one never made it into the jar: rejected: SameSiteNoneInsecure. The others, plus the session cookie, were then tested with six kinds of request from three places: the app itself, the sibling https://pages.notes.test:5104, and the unrelated https://attacker.test:5104. Chrome’s record of which cookies each request carried:
| Request to the app | From notes.test | From pages.notes.test | From attacker.test |
|---|---|---|---|
fetch with credentials | all | all | none |
<img> | all | all | none |
<iframe> | all | all | none |
| Form POST into an iframe | all | all | none |
| Link, top-level GET | all | all | lax, none, unset, __Host-sid |
| Form POST, top-level | all | all | none, unset |
“All” means strict, lax, none, unset and __Host-sid were all sent. Several things stand out.
From the sibling subdomain, every cookie went on every request, Strict included. SameSite is about sites, and pages.notes.test is the same site. Nothing in this table protects the app from its own subdomains.
From attacker.test, SameSite=None went everywhere, including into a cross-site iframe and a cross-site fetch. That’s third-party cookie behavior, and Chrome 157 in a default headless profile still allows it. Browsers with third-party cookie blocking (Safari, Firefox’s default protections, or Chrome under some settings) would withhold none from the iframe and fetch rows. An app shouldn’t rely on that either way.
The Lax cookies (lax and __Host-sid) were withheld from everything cross-site except the top-level link. The cookie with no attribute, unset, behaved like Lax, except that it also went on the top-level POST, because it was less than two minutes old.
The Strict cookie was withheld even from the link. This is its usual drawback: alice clicking a link to one of her notes from her email would arrive at the app looking logged out, because that navigation started on another site. Some apps handle this by using two cookies, a Lax one that’s enough to show pages and a Strict one required for changes. For this app, Lax on the session cookie is a reasonable default, and chapter 3 adds protections that don’t depend on the cookie at all.
Under schemeful same-site, an http:// page on the same domain also counts as another site for these rules.
What the fixes bought
| Attack | Version one | With __Host-sid, HttpOnly, Secure, SameSite=Lax, rotation |
|---|---|---|
| Script reads the session ID | document.cookie returns it | Hidden |
| Plain-HTTP request on the same host | Cookie sent in clear text | Withheld (SecureOnly) |
Sibling subdomain plants Domain=notes.test | Accepted, becomes alice’s session at login | __Host- cookie rejected; old ID deleted at login |
| Replaying a session after logout | Rejected in v1 (user_id cleared) | Rejected, row deleted |
| Cross-site top-level POST carries the session | Yes, in the first two minutes of the cookie | Withheld |
Where this leaves things
The session cookie is now as tight as cookie attributes can make it. Scripts can’t read it, it never crosses plain HTTP, no subdomain or HTTP page can plant one, a session ID chosen before login is useless after it, and logging out actually ends the session. Cross-site requests started by attacker.test go without it, except for top-level navigations.
That exception, and the table above, mark the remaining gaps. SameSite=Lax still sends the cookie when another site navigates the browser to the app with GET, and the app has a route that changes data on GET. And SameSite doesn’t apply at all to pages.notes.test, which can post any form it likes with alice’s full session attached. Both are ways for another page to make alice’s browser perform an action she never chose. Chapter 3 runs those forged requests against the app and closes them.