CORS · capstone
Capstone project: Project: CORS on Both Sides
The front end can now do its whole job, in four requests:
POST /tokenswith credentials, to get a token for ana’s. GET /bookswith the token, after a preflight.PUT /books/1with the token and abody, after a preflight. GET /mewith credentials, to show who is logged in.
Nothing yet says what a page on another
Four tests
All four go into tests/test_attacks.py. Three of them start the same way, with ana logged in through the reading
state = steps.keyed_state()
jar = []
send = ana_logs_in(state, jar)
After that, a page’s request is visit.visit(page_origin, wanted, jar, send), and result["response"] is what the page’s script may read, or None.
test_the_front_end_can_work plays the four requests above from steps.APP and asserts on what the page read each time: a token, a status of 200, two books, the user ana.
test_attack_another_origin_cannot_read_the_list fetches /books with ana’s token, and /me with credentials, from steps.NOTES and from steps.OTHER. Every response must be None. First it asserts that the same fetch of /books works from steps.APP, so that the test cannot pass against a server on which nobody can read.
test_attack_another_origin_cannot_get_a_token sends the POST /tokens of Lesson 5 from the two other origins. The page reads nothing, and that alone proves too little: a server could make a token and merely not show it. A page never sees an answer it may not read. A test may:
result = visit.visit(steps.NOTES, wanted, jar, send)
assert result["response"] is None
assert "token" not in helpers.json_of(send(result["requests"][0]))
test_cors_does_not_stop_a_plain_client uses no browser at all. It hands GET /books with a token and no Origin header straight to reading_server.handle and asserts 200 and two books.
The tests pass as soon as you write them, because Lesson 5 left the server correct. See two of them fail once. Put return True as the first line of origin_allowed, or take and values[0] in trusted out of cors_headers, and run:
python -m pytest -q -k another_origin
Each edit gives 1 failed, 2 passed: the token test for the first, the list test for the second. Undo both. Check my work does the same with servers of its own that each make one mistake.
One place
Five lines in five branches now add the CORS answer, and some responses miss theirs. Ask for /about twice from the front end, the second time with the etag of the first answer:
python -c "
import sys
sys.path.insert(0, 'tests')
import helpers, reading_server
state = reading_server.sample_state()
headers = {'Origin': 'https://app.reading.example'}
first = reading_server.handle(helpers.make_request('GET', '/about', headers=headers), state)
headers['If-None-Match'] = first['headers']['etag']
again = reading_server.handle(helpers.make_request('GET', '/about', headers=headers), state)
print(again['status'], list(again['headers']))
"
It prints 304 ['etag', 'cache-control']. not_modified() copies those two headers and drops the rest, vary among them. A 304 must carry the Vary that its 200 would have carried, so that a cache keeps one origin’s answer apart from another’s.
The remedy is the one of Chapter 8’s project: one place. cors_rule(segments) names the answer a path gets:
| Path | Rule |
|---|---|
/about | "public" |
/books and /books/{id} | "token" |
/me and /tokens | "session" |
every other path, /books/delete among them | None |
Then one block in route(), after the lines that replace a 200 with a 304 and before the no-store rule, applies it:
rule = cors_rule(segments)
if rule is not None:
extra = cors_headers(request, state["trusted_origins"], credentials=rule == "session")
for name in extra:
response["headers"][name] = extra[name]
Remove the five add_headers lines of Lessons 2 to 5. Every response that reaches this block now leaves with the same answer: the 304, a 404, a 405, and the 401 for a missing token, which the front end can now read and explain to its user. One response of a CORS route never reaches it: the 403 of the Origin check on POST /tokens returns from the first lines of route(). It goes to an origin outside the set, or to a request without an Origin header, and neither would get a permission anyway. A path that gets None gets no CORS header at all. Run the python -c line again: the list ends in 'vary', 'access-control-allow-origin'.
What CORS did not do
Run python pl_sec.py cors and read its last request:
--- a client that is not a browser: GET /books with the token and no Origin header ---
your server answered 200
access-control-allow-origin: (not sent)
vary: Origin
books in the answer: 2. This client never looks at a CORS header.
Every Access-Control- header is addressed to a browser. Without them, a browser shows a page nothing from a simple request and never sends one that needs a preflight. That is real, and it is the browser’s doing. A client that is not a browser ignores all of it.
So CORS is not access control. It never stops a client that is not a browser. What protects the server is what you built before this chapter: the token check, the owner check, the Origin check, and the form token.
You remove every CORS header from the server. Which request does the server now refuse that it answered before?
Milestones
The four tests, two of them seen failing once.
python -m pytest -qends with41 passed.cors_ruleand the block, with the five old lines gone. Still41 passed.python pl_sec.py cors:the front end read 4 of 4 responses; pages on other origins read 0 of 4.Row F12 of
SECURITY.md, extended in your own words for the reading half. It names the two new attack tests beside the four it has, and saysclosed in 9.6.
One step is not graded. The supplied cors_compare.py sends five requests to your handle() and to Starlette’s CORSMiddleware, a ready-made CORS answer from a Python web toolkit, inside one program, and prints both answers side by side. Differences are expected and are not errors in your code. Where Starlette is not installed, the file says so in one line and stops.
The model does not hide Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma, and whatever the server lists in Access-Control-Expose-Headers. Your tests read only bodies and statuses.
Every promise this service makes so far, from the Secure
Then press Check my work.
Task
Write four tests, put the server’s
In
tests/test_attacks.py, below the Chapter 9 comment, write four tests. The first three start withstate = steps.keyed_state(),jar = [], andsend = ana_logs_in(state, jar), and make every request of a page withvisit.visit(page_origin, wanted, jar, send).test_attack_another_origin_cannot_read_the_list(): withheaders = {"Authorization": steps.bearer_for(state, "ana", "correct horse")}, fetchsteps.OWN + "/books"fromsteps.APPand assert status 200. Then fetch it fromsteps.NOTESandsteps.OTHER, and fetchsteps.OWN + "/me"with"credentials": Truefrom both, and assert thatresult["response"]isNoneeach time.test_attack_another_origin_cannot_get_a_token(): withwanted = {"kind": "fetch", "method": "POST", "url": steps.OWN + "/tokens", "credentials": True}, assert that the response tosteps.APPholds atoken. Forsteps.NOTES, assert that the response isNoneand that"token"is not inhelpers.json_of(send(result["requests"][0])).test_the_front_end_can_work(): fromsteps.APP, get a token from/tokenswith credentials, fetch/bookswith it, change a book withPUTand abody, and fetch /mewith credentials. Assert on each response the page read.test_cors_does_not_stop_a_plain_client(): handhelpers.make_request("GET", "/books", headers=...)with a token and noOriginheader toreading_server.handle(...), and assert status 200.
In
reading_server.py, writecors_rule(segments):"public"for["about"],"session"for["me"]and["tokens"],"token"for["books"]and for aof two that starts with "books"and does not end in"delete", andNonefor every other list.In
route(), after the lines that replace a 200 withnot_modified(response)and before theno-storerule, callcors_rule(segments). When the rule is notNone, copy every header ofcors_headers(request, state["trusted_origins"], credentials=rule == "session")intoresponse["headers"]. Remove theadd_headerscalls of Lessons 2 to 5.Run
python -m pytest -quntil it ends with41 passed, then runpython pl_sec.py cors.In
SECURITY.md, extend row F12 for the reading half. Addtest_attack_another_origin_cannot_read_the_listandtest_attack_another_origin_cannot_get_a_tokento the tests it names, and set its status toclosed in 9.6.
Then press Check my work. It calls cors_rule with fifteen lists of segments. It sends requests from the front end’s handle(): a 401, a 404, three 405s, a 415, a HEAD, and two 304s must each carry the CORS answer, and six paths without a rule must carry none. It passes the bytes of a preflight through http_adapter in memory. It repeats the checks of Lessons 2, 4, and 5 for the model and the server. It runs your four tests, selected by name, against servers it puts in place of reading_server.handle, with a browser model of its own in place of visit.visit: each test must pass against a server that answers CORS as this chapter asks, and fail against a server with one mistake, which is telling every origin it may read, handing out a token whatever the Origin header says, sending no CORS header at all, or refusing every request without an Origin header. The two attack tests must also fail against the server that sends no CORS header. No test may open a connection. It reads row F12 of SECURITY.md; its wording is not judged. Nothing from your