1 — Where the browser draws lines
Every attack in this track runs against one small application: a notes service. People sign up, log in, write notes, share a note with another user, search their own notes, set an avatar by pasting an image URL, and an admin page lists the accounts. It is a few hundred lines of Python, and its first version was written the way a lot of first versions are written: it works, and nobody has yet asked what else it does.
Before attacking it, it helps to know what the browser already protects on its own. The browser sits between every site a person visits, and it enforces one rule above all others: a page from one origin can’t read what belongs to another. That rule is the reason the web works at all with many tabs open at once. It is also narrower than it sounds, and the gap between “can’t read” and “can’t send” is where the next few chapters live.
The first version of the app
The app is Flask with Jinja2 templates, backed by PostgreSQL 16. Four tables hold everything:
CREATE TABLE users (id serial PRIMARY KEY, username text UNIQUE NOT NULL, password_hash text NOT NULL,
is_admin boolean NOT NULL DEFAULT false, avatar bytea);
CREATE TABLE sessions (sid text PRIMARY KEY, user_id int REFERENCES users, csrf text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE notes (id serial PRIMARY KEY, owner_id int NOT NULL REFERENCES users, title text NOT NULL,
body text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE shares (note_id int REFERENCES notes ON DELETE CASCADE, user_id int REFERENCES users,
PRIMARY KEY (note_id, user_id));
Sessions are rows in PostgreSQL. The browser holds only a random session ID in a cookie called sid, and every request looks that ID up. Version one hands a session to every visitor on their first request, logged in or not, and logging in simply attaches a user to the session the browser already has:
@app.before_request
def load_session():
g.sid = request.cookies.get("sid")
g.sess = db().execute("SELECT * FROM sessions WHERE sid=%s", (g.sid,)).fetchone() if g.sid else None
if g.sess is None:
# every visitor gets a session up front, logged in or not
g.sid = g.new_sid = new_session()
...
@app.route("/login", methods=["GET", "POST"])
def login():
...
db().execute("UPDATE sessions SET user_id=%s WHERE sid=%s", (u["id"], g.sid))
return redirect("/")
The cookie goes out with no attributes at all:
Set-Cookie: sid=eVkFNRD-9aqEet43_Swgzwkf04TojapxO8TlR2QjzWY; Path=/
The rest of the routes are what the description promises. GET / lists the notes a user owns or has been shared. POST /notes creates one. GET /notes/<id> shows it, rendering the body as HTML so people can use bold and links. /notes/<id>/share takes a username and accepts both GET and POST, because Flask makes that one word in a decorator. GET /search?q= searches the user’s notes and prints “Results for” followed by the query. GET /api/notes returns the same notes as JSON for a future mobile client, and POST /api/notes creates one from a JSON body. Passwords are hashed with Werkzeug’s generate_password_hash, which defaults to scrypt.
Each of those choices comes back later. Two users exist: alice, who has a note titled “Door code” containing “The side door code is 4417.”, and mallory, who would like to read it.
The lab
Everything runs on one laptop, bound to 127.0.0.1. Attacks need more than one site, so the lab gives them names. Headless Chrome 157 (157.0.8103.40) is started with a host resolver rule that maps a few made-up names to the loopback address:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new \
--remote-debugging-port=5105 --user-data-dir=/tmp/profile-xyz \
--host-resolver-rules="MAP notes.test 127.0.0.1, MAP *.notes.test 127.0.0.1, MAP attacker.test 127.0.0.1"
.test is reserved for exactly this kind of use, so none of these names exist on the internet. The app runs at http://notes.test:5101. A second small Flask server plays the attacker: it serves static pages and logs every request it gets, on port 5102 as attacker.test and on port 5103 as pages.notes.test, a subdomain of the app’s own domain. Later chapters give that subdomain a story. For now it is just another host.
A short Node script drives Chrome over the DevTools Protocol. For every request it records the URL, the method, which cookies were attached and, for cookies that weren’t, the reason Chrome gives. The app logs the same requests from the other side, with their Origin and Cookie headers. Every result below comes from those two records.
What an origin is
An origin is three things: a scheme, a host and a port. Two URLs share an origin only if all three match exactly. The HTML standard defines it as a tuple, and Chrome computes it the same way. Asked for new URL(u).origin on a handful of URLs, the browser answered:
| URL | Origin |
|---|---|
http://notes.test:5101/notes/1 | http://notes.test:5101 |
https://notes.test:5101/ | https://notes.test:5101 |
http://notes.test:5102/ | http://notes.test:5102 |
http://pages.notes.test:5101/ | http://pages.notes.test:5101 |
http://localhost:5101/ | http://localhost:5101 |
http://127.0.0.1:5101/ | http://127.0.0.1:5101 |
The path never matters. Everything else does. http and https on the same host are different origins. So are two ports on the same host, and a subdomain and its parent. localhost and 127.0.0.1 reach the same server, yet they are different hosts as far as the browser is concerned, so they are different origins too. No DNS lookup is involved in the comparison; it is a comparison of strings.
The same-origin policy is the set of rules that use this tuple. In outline: a script running in a page can freely read anything from its own origin, including responses to its requests, the DOM of same-origin frames, and the origin’s storage. Across origins, it can cause a great deal to happen but read almost nothing.
Reading from the same origin
Alice logs in through the real login form at http://notes.test:5101/login. Then, from a page on the app itself, a script asks for her notes:
fetch("/api/notes").then(r => r.text())
[{"body":"The side door code is 4417.","id":1,"owner":"alice","title":"Door code"}]
The browser attached the sid cookie, the server found alice’s session, and the script got the body back. That is ordinary same-origin behavior and exactly what the app’s own pages rely on.
Reading from another origin
Now the same browser opens http://attacker.test:5102/blank.html, a page that says “Nothing to see.” The page runs the same request, aimed at the app:
fetch("http://notes.test:5101/api/notes", { credentials: "include" })
.then(r => r.text())
.catch(e => "error: " + e)
The script got error: TypeError: Failed to fetch, and the console explained why:
Access to fetch at 'http://notes.test:5101/api/notes' from origin 'http://attacker.test:5102'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the
requested resource.
The important part is on the server side. The app’s log shows the request arrived:
GET notes.test:5101/api/notes Origin=http://attacker.test:5102 Cookie=None
"GET /api/notes HTTP/1.1" 401 -
The browser sent the request, the server processed it and answered, and then the browser refused to hand the answer to the script. The same-origin policy is enforced on the way back. A server that does something when it receives a request has already done it by the time the browser decides the response is off limits.
(The request also went without a cookie, and got a 401 because of it. That detail matters a lot and gets its own section below.)
Other ways of asking produce the same split. A fetch with mode: "no-cors" doesn’t fail; it returns an opaque response, one the script can hold but not look into:
{"type":"opaque","status":0,"body":""}
The real status was 401, as the server’s log and DevTools both show, but the script sees status 0 and an empty body. Opaque responses exist so a page can still cache or forward a cross-origin resource it isn’t allowed to read.
Pointing an <img> or a <script> tag at the JSON API sent the request too. Chrome then blocked both responses with net::ERR_BLOCKED_BY_ORB. ORB, opaque response blocking, is Chrome’s filter for responses that are plainly not images, scripts or stylesheets, so that JSON or HTML from another origin never even reaches the process rendering the attacker’s page. The page saw only an error event.
Frames show the line most clearly. The attacker page created an iframe on http://notes.test:5101/notes/1, and the frame loaded. Reaching into it was another matter:
f.contentDocument // null
f.contentWindow.location.href
// SecurityError: Failed to read a named property 'href' from 'Location': Blocked a frame
// with origin "http://attacker.test:5102" from accessing a cross-origin frame.
f.contentWindow.location.href = "http://attacker.test:5102/blank.html#moved"
// allowed
The attacker can embed the app, can’t read the frame’s document or even its URL, but can navigate it somewhere else. The HTML standard keeps a short list of cross-origin properties that stay reachable on a window (location for writing, postMessage, close, frames, length, a few others). Everything else throws.
The last test is a plain HTML form. The attacker page built one and submitted it:
const f = document.createElement("form");
f.method = "POST";
f.action = "http://notes.test:5101/notes";
f.innerHTML = '<input name=title value="from attacker.test">' +
'<input name=body value="posted by a form on another site">';
document.body.append(f);
f.submit();
Nothing stopped it. The browser navigated the tab to the app, the server created the note and redirected to it, and alice’s browser ended up at http://notes.test:5101/notes/3. A form submission is a navigation, and a navigation is allowed to go anywhere, with any method a form supports.
Put together, the measurements look like this:
From attacker.test:5102, aimed at the app | Request sent? | Response readable by the page? |
|---|---|---|
fetch (CORS mode) | Yes | No, blocked by CORS |
fetch with mode: "no-cors" | Yes | No, opaque |
<img>, <script> | Yes | No, blocked by ORB |
<iframe> | Yes | No, cross-origin frame |
| Form POST | Yes | No, but the tab navigates to the result |
Every request was sent. None of the answers came back to the attacker’s code.
That is the design, not a gap. The web has always let pages link to, embed and submit to other sites; that is what makes it a web. The same-origin policy was layered on top to protect reading, because reading is where private data leaks. Sending was left mostly open, and the parts of the platform that later made sending more powerful, such as fetch with JSON bodies and custom headers, were made to ask permission first (the CORS preflight, covered in chapter 6). The old forms and tags kept the abilities they always had.
Sites are bigger than origins
The browser has a second, coarser notion of “the same place”: the site. A site is the scheme plus the registrable domain: the domain one label below a public suffix. notes.test, pages.notes.test and www.pages.notes.test all belong to the site https://notes.test (for HTTPS). Ports don’t count at all.
What counts as a public suffix isn’t something a browser can compute from the name. .com is obviously one. So is .co.uk, where example.co.uk is the registrable domain and co.uk is not. And so is github.io: GitHub lets anyone publish at <name>.github.io, and treats each of those as a separate owner. The list of such suffixes is the Public Suffix List, a file maintained at publicsuffix.org and compiled into browsers. Its ICANN section covers country and generic top-level domains; its private section covers services like github.io that hand out subdomains to strangers. The registrable domain is sometimes called “eTLD+1”, the effective top-level domain plus one label.
Chrome tells every server how it classified a request in the Sec-Fetch-Site header (part of Fetch Metadata, which chapter 3 uses as a defense). That makes the site computation directly observable. Chrome only sends the header to HTTPS origins and to localhost; the app’s log above shows it absent on every plain-HTTP request to notes.test. So for this measurement the attacker server also listened on port 5104 with a self-signed certificate (Chrome ran with --ignore-certificate-errors), and the resolver rule was extended to send *.co.uk and *.github.io to 127.0.0.1 as well, so no request left the machine. A page on one host fetched /echo on another, and the server reported the header it received:
| Page | Request to | Sec-Fetch-Site |
|---|---|---|
https://notes.test:5104 | https://notes.test:5104 | same-origin |
https://notes.test:5104 | https://pages.notes.test:5104 | same-site |
https://notes.test:5104 | https://attacker.test:5104 | cross-site |
https://shop.example.co.uk:5104 | https://blog.example.co.uk:5104 | same-site |
https://example.co.uk:5104 | https://other.co.uk:5104 | cross-site |
https://alice.github.io:5104 | https://bob.github.io:5104 | cross-site |
shop.example.co.uk and blog.example.co.uk share a registrable domain, so they are same-site. alice.github.io and bob.github.io look exactly as related, but github.io is on the list, so each is its own site. Without the list, any page on any GitHub Pages site would count as “same-site” with every other one.
.test is not on the list, so notes.test is a registrable domain and pages.notes.test is same-site with the app. Keep that in mind: whoever controls any subdomain of the app’s domain is, to the browser, part of the same site.
The scheme counts too. Chrome’s “schemeful same-site”, on by default since Chrome 89, treats http://notes.test and https://notes.test as different sites. (A fetch from an HTTPS page to http://notes.test:5102 couldn’t be measured this way at all; Chrome blocked it as mixed content before sending.)
Why have two notions? Origins draw the line for reading. Sites draw it for things that were never isolated by origin in the first place, cookies above all, and later for features built to fix cookies.
Cookies don’t know about ports
Cookies are older than the same-origin policy and follow their own, looser rules. A cookie belongs to a host, not to an origin: scheme and port aren’t part of its identity. RFC 6265 says so plainly in its security section: “Cookies do not provide isolation by port. If a cookie is readable by a service running on one port, the cookie is also readable by a service running on another port of the same server.”
The browser shows it in two steps. A visit to http://localhost:5101/ let the app set its sid cookie. Then the browser opened a page served by the attacker server on http://localhost:5102, a different origin, and ran:
document.cookie
// "sid=_pI6QDyEQLQEX1ZzIbIRkOyVbhGspugdGDqP0ZK_sWc"
A different program on a different port read the app’s session cookie out of the jar. And when the same page fetched http://localhost:5101/api/notes, Chrome attached sid to the request. The response was still unreadable (CORS blocked it), because reading follows origins, but the cookie went along, because cookies follow hosts and sites.
So the two rules don’t line up. The same-origin policy says the page on port 5102 can’t read the app’s responses. The cookie rules say it can read the app’s session cookie directly and send it along on requests. Anything else on the same host, a dev tool, a second service, a leftover admin panel, shares the app’s cookies.
Which requests carry the cookie
Back to the detail from earlier: the cross-origin fetch from attacker.test arrived without a cookie. Chrome’s DevTools record says why:
GET http://notes.test:5101/api/notes [Fetch] status=401
cookies withheld: sid (SchemefulSameSiteUnspecifiedTreatedAsLax)
The app never said anything about when its cookie may travel, so Chrome applied its default. Since Chrome 80 (rolled out through 2020), a cookie without a SameSite attribute is treated as SameSite=Lax: it is left off requests that come from another site, except top-level navigations with a safe method like GET. The <img>, <script>, iframe and no-cors requests from attacker.test were all withheld for the same reason. So in this browser, for this cookie, the attacker’s reads would have failed twice over: no cookie on the way out and no access to the response on the way back.
That sounds like the end of the matter. It isn’t, and the same run showed three ways the cookie still went along.
The first is a same-site page. The browser loaded http://pages.notes.test:5103/blank.html, a page on a sibling subdomain, and made the identical fetch:
GET http://notes.test:5101/api/notes [Fetch] status=200
cookies sent: sid
FAILED: net::ERR_FAILED CORS:MissingAllowOriginHeader
The app’s log agrees:
GET notes.test:5101/api/notes Origin=http://pages.notes.test:5103 Cookie=sid=0JWCOYpJ...
The sibling is a different origin, so it still couldn’t read the 200 response. But it is the same site, so SameSite never applied, and the request ran with alice’s full session. Any request from that page that changes something will succeed.
The second is a top-level navigation. A link on attacker.test pointing at the app, clicked, took the cookie along:
GET http://notes.test:5101/ [Document] status=200
cookies sent: sid
That’s what Lax is designed to allow. Someone following a link to the app from an email or a search result should arrive logged in.
The third is a top-level POST, and it depends on time. The form submission from attacker.test above, the one that created a note, carried the cookie:
POST notes.test:5101/notes Origin=http://attacker.test:5102 Cookie=sid=0JWCOYpJ...
That was a cross-site POST, which Lax is supposed to block. Chrome has a deliberate exception for cookies that never chose a SameSite value: for the first two minutes after the cookie is set, it is still sent on top-level cross-site POSTs. The exception was added so that single sign-on flows which POST back to a site immediately after it sets a cookie kept working, and Chrome’s documentation calls it a temporary intervention. In Chrome 157 it is still there. A second run measured it: one forged POST right after alice logged in, and another after waiting:
POST at ~0s after login -> Cookie=sid=KBGJKH65... (sent)
POST at ~131s after login -> cookies withheld: sid (SchemefulSameSiteUnspecifiedTreatedAsLax)
The window counts from when the cookie was set, not from login. In version one the cookie is set on the first visit to any page, so the window is open for the first two minutes of every new session.
None of this depends on Chrome alone. Other browsers made different choices about the default, and older ones send cookies on every request. A server that wants a particular behavior has to ask for it.
What the browser guarantees, and what it doesn’t
The measurements reduce to a few statements.
A page can make the browser send almost any request a form or a tag can express to any other site: GET or POST, with URL-encoded or multipart or plain-text bodies, in a frame or as a top-level navigation. The server will receive it and act on it.
A page can’t read the response to a request on another origin unless that origin opts in with CORS. Not the body, not the status, not the frame’s document.
The origin (scheme, host, port) is the line for reading. The site (scheme plus registrable domain, from the Public Suffix List) is a wider line, and cookies follow it, or follow something looser still: they ignore ports entirely.
So the app’s protection against attacker.test rests on a default that Chrome applied to a cookie the app never configured, with a two-minute hole in it. Its protection against pages.notes.test and anything else on the same host rests on nothing.
Where this leaves things
Nothing in the app is fixed yet. What is established is the ground the rest of the track stands on: the same-origin policy stops other origins from reading the app’s pages and API, and it doesn’t stop them from sending requests to it.
Whether those requests carry alice’s identity depends entirely on the session cookie, and the measurements show cookies going along anyway: to a sibling subdomain, to another port on the same host, on links from anywhere, and on a cross-site form POST in the first two minutes of a session. The app’s cookie has no attributes, so every one of those behaviors is a browser default. Chapter 2 looks at the cookie itself: what the attributes do, which requests each one lets through, and how a cookie that someone else planted can become alice’s session.