Origins and Cross-Site Requests · capstone
Capstone project: Project: Requests from Somewhere Else
The delete form now has two guards of your own, a token and an Origin check, and one attack test. That test cannot say which guard stopped the forgery. Take either one away, and it still passes.
This project proves each defense alone. Proven has a plain meaning here: a test that still passes after you have given the attacker everything that the other defense withholds.
Switch a defense off by handing it over
No switch is added to the server. A test changes what the attacker holds.
The token alone.
test_attack_token_alone_stops_the_neighboradds the neighbor to the trusted set before anything else. TheOrigincheck now lets the forged form through. The form carries no token, and the book must stay.The
Origincheck alone.test_attack_origin_check_alone_stops_the_neighborreads Ana’s token out of the state, which no page can do, withstate["sessions"][jar[0]["value"]]["csrf"]. It posts the form with the body("id=1&csrf=" + token).encode("utf-8"). The token check now passes, and the book must stay.
The first of the two starts like this:
def test_attack_token_alone_stops_the_neighbor():
"""The Origin check is handed to the attacker: the neighbor is trusted. The token alone must stop the forgery."""
state = reading_server.sample_state()
state["trusted_origins"].add(steps.NOTES)
Both tests post from steps.NOTES. Both assert that the
Your test adds https://notes.reading.example to state["trusted_origins"] and then posts the forged form without a token. What does a pass prove?
See each of these two tests fail once. Put return True as the first line of csrf_matches, run python -m pytest -q, and take the line out again. Then do the same in origin_allowed. Each run ends with one failure, and it is the test for that defense.
Two more tests complete the picture. test_the_page_can_still_delete_its_own_book posts the form from steps.OWN with the token and asserts that book 1 is gone. A defense that stops the honest page as well has broken the service. In tests/test_browser.py, test_samesite_alone_does_not_stop_the_neighbor stands beside your test of Lesson 5: the form from steps.NOTES carries the cookie. Together the two record what SameSite stops and what it does not.
One place for the Origin check
The delete form is not the only POST that a cookie authorizes. Login, logout, the page size, and a new token all read the session cookie. The project file
COOKIE_POSTS = [["login"], ["logout"], ["prefs"], ["tokens"], ["books", "delete"]]
Take the check out of the delete branch and make it the first statement of route() after segments is set. A POST whose segments are in COOKIE_POSTS, and whose Origin is not allowed, gets the 403 at once, with a
Why a list, and not a rule such as “every POST except the book routes”? The path of the delete form has the shape of a book’s path, so an POST /v0/books, which has answered 308 since
The check on /login has a cost, and it is chosen: a Origin can no longer log in.
No GET changes state
test_attack_a_get_cannot_delete_a_book follows a link from another site. The link’s steps.OWN + "/books/delete?id=1&csrf=" + token:
result = visit.visit(steps.OTHER, {"kind": "link", "url": url}, jar, send)
A followed link is a top-level GET, and a top-level GET, from a link or from a form sent with GET, is the one kind of cross-site request that carries a Lax cookie. Assert that the cookie went along and that the book stayed. Your server answers 405 today, because the route allows POST only, and this test keeps it that way.
Milestones
Four tests in
tests/test_attacks.pyand one intests/test_browser.py, the first two of them seen failing once.The check at the top of
route(). Your tests cannot see this step: they pass before it and after it. Check my work tries the other routes inCOOKIE_POSTSfor you.Two runs: the tests, and the scenario. The scenario is for you to read, and it is not graded.
python -m pytest -q
python pl_sec.py cross-site-delete
The first run ends with 35 passed. In the second, the neighbor’s form is now refused, and so is the form from other.example, before a
--- a page on https://notes.reading.example posts the same form ---
cookie sent: yes
origin: https://notes.reading.example
the server answered: 403 (pl_sec sees this; the page does not)
book ids left: 1, 2
The last lines say:
--- result ---
0 of 2 forged forms deleted a book; the page's own form deleted its book
Row F12 of
SECURITY.md, in your own words. Its Test cell names the four attack tests of this chapter. Its status isopen: the forgery half closed in 8.8, because the finding has a second half.
What is still open
That second half is about reading. https://app.reading.example is a second front end of the reading list. Its pages should be allowed to read what your server answers, and today the
Task
Prove each defense alone, put the Origin check in one place for five routes, and record the finding.
Every test logs ana in through the model first, as in Lesson 5, and reaches the code through its state["sessions"][jar[0]["value"]]["csrf"]. The form is the delete form of Lesson 5, posted with visit.visit(page, form, jar, send).
In
tests/test_attacks.py, below your test of Lesson 5, write four tests.test_attack_token_alone_stops_the_neighbor(): firststate["trusted_origins"].add(steps.NOTES). Post the form with the bodyb"id=1"fromsteps.NOTES. Assert that"cookie"is in the headers of the request, and that1 in state["books"].test_attack_origin_check_alone_stops_the_neighbor(): post the form with the body("id=1&csrf=" + token).encode("utf-8")fromsteps.NOTES, and leavestate["trusted_origins"]alone. Assert the same two things.test_attack_a_get_cannot_delete_a_book(): follow a link fromsteps.OTHER,{"kind": "link", "url": steps.OWN + "/books/delete?id=1&csrf=" + token}. Assert the same two things.test_the_page_can_still_delete_its_own_book(): post the form with the token fromsteps.OWN, and assert that1 not in state["books"].
In
tests/test_browser.py, writetest_samesite_alone_does_not_stop_the_neighbor(): post the form with the bodyb"id=1"fromsteps.NOTESand assert that"cookie"is in the headers of the request.In
routeinreading_server.py, make this the first statement aftersegmentsis set: whenmethod == "POST",segments in COOKIE_POSTS, andorigin_allowed(request, state["trusted_origins"])is false, returnjson_error(403, "this request did not come from a page of this service"). Take the check out of the"/books/delete"branch.Run
python -m pytest -quntil it ends with35 passed, then runpython pl_sec.py cross-site-delete.In
SECURITY.md, fill row F12. Its Test cell namestest_attack_another_origin_cannot_delete_a_bookand the threetest_attack_tests of this lesson. Its status isopen: the forgery half closed in 8.8.
Then press Check my work. It logs a user of its own in and drives your server in memory, with a browser model of its own. Each of the five routes in COOKIE_POSTS gets a POST with a valid Origin is missing, null, a neighboring host, or another site: each must get a 403 and change nothing. A POST and a PUT that a token authorizes must still be served without an Origin, POST /v0/books must still answer 308, and POST /me, /about, and / must still answer 405. The five routes must still work from the service’s own Origin check, neither, a GET that deletes, the delete form refused from every page, a Lax cookie on a cross-site form post, and a cookie withheld from the neighbor. No test may open a connection. Row F12 is read as a table row; its wording is not judged. Nothing from your