0%

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 ends with this line:

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 , so the lie gains it nothing.

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 is 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 that passes it:

A comparison that looks rightAn origin it lets in
The value starts with https://reading.examplehttps://reading.example.other.example
The value contains reading.examplehttps://notreading.example
The value is on the same site as the reading listhttps://notes.reading.example, the neighbor itself
The host is reading.example, whatever the schemehttp://reading.example

A server accepts an Origin that starts with https://reading.example. Which origin gets in?

A request holds each header as a list of values, because a client can send the same header twice. So 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 above already does: "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.

DefenseA form from other.exampleA form from notes.reading.example
SameSite=Laxstopped: the cookie stays homenot stopped: same site, so the cookie travels
The token in the formstopped: it cannot read the pagestopped: it cannot read the page
The Origin checkstopped: not in the setstopped: 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, with Python, uses the same Origin check to guard a WebSocket handshake.

Which defense does not stop a forged form from notes.reading.example?

Your task

  1. Write origin_allowed(request, trusted) in reading_server.py: exactly one origin value, and that value in the set.

  2. In the "/books/delete" branch of route, for a POST, ask it before anything else. When origin_allowed(request, state["trusted_origins"]) is false, the response is json_error(403, "this request did not come from a page of this service"), and the is 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.

  1. In reading_server.py, write origin_allowed(request, trusted). Read request["headers"].get("origin", []), a of values. Return False unless 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.

  2. In the "/books/delete" branch of route, for a POST, call origin_allowed(request, state["trusted_origins"]) before the is read. When it returns False, the response is json_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 and a trusted set of its own, which holds two , one of them with a . The two must be allowed. These must be refused: a request without the header, with two 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 and token and with no 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 is graded.