Origins and Cross-Site Requests · practice
SameSite and the Origin Header
Go back to the output of python pl_sec.py cross-site-delete in Lesson 5. Each forged form had a line that nothing has used so far:
origin: https://notes.reading.example
visit() adds an origin header to every request that is not a GET or a HEAD, as a browser does. So the forged form has said where it came from all along, and your server never looked.
A header the page cannot set
The header is worth reading because of who writes it. The browser writes Origin, and a page’s script may not set it. visit() holds to that rule: it refuses a wanted that tries. See it once. In this line, a page on the neighbor claims to be the reading
python -c "import visit; visit.visit('https://notes.reading.example', {'kind': 'fetch', 'method': 'POST', 'url': 'https://reading.example/logout', 'headers': {'Origin': 'https://reading.example'}}, [], print)"
The
ValueError: a page cannot set the Origin header; the browser writes it
A program that is not a browser can write any Origin it likes. It has no browser of Ana’s to attach her
Exact, from a set
sample_state() has held a set since the start of this chapter: state["trusted_origins"], with one member, "https://reading.example". The check asks one question, whether the header’s in that set. On a set of texts, in is true only for a text that equals a member from its first character to its last.
Every looser comparison lets someone in. Here are four, each with an
| A comparison that looks right | An origin it lets in |
|---|---|
The value starts with https://reading.example | https://reading.example.other.example |
The value contains reading.example | https://notreading.example |
| The value is on the same site as the reading list | https://notes.reading.example, the neighbor itself |
The host is reading.example, whatever the scheme | http://reading.example |
A server accepts an Origin that starts with https://reading.example. Which origin gets in?
A request origin_allowed(request, trusted) starts with request["headers"].get("origin", []). It returns False unless that list has exactly one value. Then it answers whether the value is in trusted. A request with two Origin headers is refused, whatever the first one says.
Missing and null
A browser sends Origin: null in some cases: from a page that was told to send no referrer, from a sandboxed frame, and from a file opened from disk. An old or unusual client sends no Origin at all. Refuse both. The "null" is not in the set, and a missing header is a list of no values.
This refusal has a cost, and the cost is chosen. A tool that posts to this route has to send the header. pl_sec.py does, and the tests in tests/test_attacks.py have sent it since the start of this chapter. It also binds a later chapter. When Chapter 10 adds security headers, it must not set Referrer-Policy: no-referrer, or the reading list’s own form would arrive with Origin: null.
The model always writes a real origin. The null cases come from the Fetch and HTML standards and are not shown here. Fetch adds two more: a request that was redirected through another origin, and a POST from an https page to an http address. An older header, Referer, carries the same idea and has more ways to be absent.
A cookie-authorized POST arrives with Origin: null. What does the server do?
Three defenses, two attackers
The delete form now has three defenses. Two attackers have appeared in this chapter: a page on other.example, and a page on the neighbor.
| Defense | A form from other.example | A form from notes.reading.example |
|---|---|---|
SameSite=Lax | stopped: the cookie stays home | not stopped: same site, so the cookie travels |
| The token in the form | stopped: it cannot read the page | stopped: it cannot read the page |
The Origin check | stopped: not in the set | stopped: not in the set |
Keep all three, because each one fails in a different way: a browser without SameSite, a bug in the page that leaks the token, a route that someone forgets. Another Python Land course, Origin check to guard a WebSocket handshake.
Which defense does not stop a forged form from notes.reading.example?
Your task
Write
origin_allowed(request, trusted)inreading_server.py: exactly oneoriginvalue, and that value in the set.In the
"/books/delete"branch ofroute, for a POST, ask it before anything else. Whenorigin_allowed(request, state["trusted_origins"])is false, the response isjson_error(403, "this request did not come from a page of this service"), and theis not looked up.
Then run the tests:
python -m pytest -q
The run still ends with 30 passed. No test fails without the new check, because the token already stops the forged form. That is a gap in the tests, and the project in Lesson 8 closes it.
Then press Check my work.
Task
Check the Origin header of the delete form, exactly and against a set.
In
reading_server.py, writeorigin_allowed(request, trusted). Readrequest["headers"].get("origin", []), aof values. Return Falseunless the list has exactly one. Then return whether that value is in the set trusted. Compare the whole text and repair nothing: no lower case, no slash removed, no prefix.In the
"/books/delete"branch ofroute, for a POST, callorigin_allowed(request, state["trusted_origins"])before theis read. When it returns False, the response isjson_error(403, "this request did not come from a page of this service"). Otherwise the branch goes on as before.
Run python -m pytest -q. It ends with 30 passed.
Then press Check my work. It calls origin_allowed with request Origin headers, with null, with an empty value, with a text that is only a part of a trusted origin, with another scheme, with a neighboring host of the same site, with a trusted host without its port, with look-alikes that start or end like a trusted origin, with upper case, with a slash at the end, and with a bare host. Then it logs a user of its own in and drives your server in memory, with a browser model of its own. A form from a neighboring host that carries the right token must leave the book in place and get a 403. Requests with the right Origin, null, or a look-alike must get a 403 and change nothing. A request with an untrusted Origin and no cookie must get a 403 and not a 401. The page’s own form must still delete. Nothing from your