Capstone: Harden the Reading List · capstone
Capstone project: Close the Cross-Origin Holes
Nine findings are closed. The two red tests that remain are about requests that a page on another site causes, and for those you have a tool: the model browser you wrote in Chapters 8 and 9. The contract says that one of its rules was weakened. So the tool comes first.
The reason is the direction of the mistake. A model that is wrong in the strict direction would refuse requests a browser sends, and you would notice at once, because the service’s own page would stop working. A model that is wrong in the generous direction is quieter. It shows attacks arriving that a real browser would have stopped, and it sends you after defenses for requests that cannot happen, while telling you nothing true about the ones that can.
Find the rule
The model has its own tests. Run them:
python -m pytest tests/test_browser.py -q
Four of the six fail, and the summary at the end names them:
test_lax_cookie_stays_home_on_a_cross_site_posttest_samesite_stops_a_form_from_another_sitetest_a_page_without_permission_cannot_readtest_a_delete_is_asked_about_first
The last two are your Chapter 9 tests, and they fail on the server’s answer, not on the model: https://other.example is told that it may read GET /about and send a DELETE. That is finding 11, and it waits until the end of this lesson. The first two are about the model.
Read the first one. It builds one SameSite=Lax mini_browser.cookies_to_send() what goes with a top-level POST from https://other.example, and expects nothing. It gets the cookie. The second test is the same mistake seen through visit.visit(): a form on another site arrives with Ana’s session cookie.
cookies_to_send() asks one SameSite, and that is where to look. The rule from Chapter 8: a Lax cookie goes with a cross-site request only when the request is top-level and its
With the weakened rule, what does the model show when a page on https://other.example posts the delete form?
Repair the rule and run the six tests again before you go on. The two model tests pass; the two Chapter 9 tests still fail until finding 11 is closed. Be careful not to overshoot: a Lax cookie still goes with a POST from the same site, and with a link followed from another site.
Finding 10
“The delete form is accepted with a form token that was issued to a different
Think about who can use that. The neighbor at https://notes.reading.example is on the same site as the reading
Your test for this finding shows it: Ana’s cookie, Ben’s token, a trusted Origin, and the book is gone. The check in delete_from_form() asks whether the token is known. The question it must ask is whether the token belongs to the session the cookie names. csrf_matches(session, sent) answers exactly that for one session, with hmac.compare_digest, and the function already has that session in its hand.
You might Origin is https://notes.reading.example, and the Origin check refuses it before the token is looked at. That is true today.
The Origin check already refuses the neighbor's form. Why close finding 10 at all?
In Chapter 8 you wrote one test with the Origin check out of the way and one with the token out of the way, to prove that either defense stops the neighbor alone. Finding 10 is the case where the token, alone, stops nobody who has an account.
Finding 11
“A page on any
Your test sends GET /about with Origin: https://other.example and finds that origin repeated in access-control-allow-origin. Chapter 9 called this reflection. It looks more careful than *, because the header names one origin. It is worse: a browser refuses to combine * with credentials, and it has no such objection to a reflected origin. So on GET /me, where the service allows credentials, a page whose requests carry Ana’s cookie could read her name. With SameSite=Lax that is the neighbor at https://notes.reading.example. A page on another site sends no cookie and reads an answer with no user in it. POST /tokens allows credentials too, and there only the Origin check stands between the neighbor and a token made with her cookie.
The repair is in cors_headers(). The permission goes only to an origin that is in trusted, compared whole. That set is state["trusted_origins"], the same one the Origin check uses, so the two decisions cannot drift apart. Keep vary: Origin on every answer, also on a refusal: the answer depends on that header either way.
Your task
Three changes, in this order: the rule in mini_browser.py, the token check in the form’s branch, and the cors_headers(). Then run everything:
python -m pytest -q
The result to expect is zero failures: eleven attack tests and six model tests.
Then press Check my work. It calls your model’s functions with cookies and sites you have not seen, and runs tests/test_browser.py against it. It checks finding 10 with two sessions of its own and finding 11 with a trusted set of its own, where an origin that only starts like a trusted one must be refused. Then it runs the contract’s sequence of requests in memory twice: your server with a correct model, and a correct server with your model, so that a mistake on one side cannot hide behind the other. That model is a model too; it is not a browser. Last, everything from Lesson 2 is checked again.
Nothing has touched a
Task
Repair the model browser, then close findings 10 and 11.
In mini_browser.py:
Run
python -m pytest tests/test_browser.py -qand repair the one weakened rule thattest_lax_cookie_stays_home_on_a_cross_site_postandtest_samesite_stops_a_form_from_another_sitepoint at. The other two failures there are finding 11. ASameSite=Laxgoes with a cross-site request only when the request is top-level and its is GET or HEAD. It still goes with every same-site request.
In reading_server.py:
Finding 10:
POST /books/deleteaccepts a form token only when it is the token of thethe request’s cookie names. Compare with csrf_matches(), and read the token from the form body only. Any other token answers 403 and the book stays.Finding 11:
cors_headers(request, trusted, *, credentials=False)writesaccess-control-allow-originonly for anthat is in trusted, compared as a whole. It returnsvary: Originon every answer, also when it refuses.
Run python -m pytest -q. All eleven attack tests and all six model tests pass.
Then press Check my work. It calls samesite_allows() and cookies_to_send() with cookies and sites you have not seen, and runs tests/test_browser.py against your model. It posts the delete form with one session’s cookie and another session’s token, with the session’s own token, and with a token in the query hmac.compare_digest. It calls cors_headers() and handle() with a trusted set of its own: trusted origins must be allowed, and a neighbor on the same site, an origin that only starts like a trusted one, null, and a missing Origin must not. It runs the contract’s sequence in memory with your server and a correct model, and with a correct server and your model. Then it repeats the checks of Lesson 2, runs your eleven tests against your files, and runs its own copy of the attack tests from Chapters 1 to 10. Nothing from your