0%

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_neighbor adds the neighbor to the trusted set before anything else. The Origin check now lets the forged form through. The form carries no token, and the book must stay.

  • The Origin check alone. test_attack_origin_check_alone_stops_the_neighbor reads Ana’s token out of the state, which no page can do, with state["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 went along before they assert anything about the book, as your test of Lesson 5 does.

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 the five:

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 written by shape leaves that form unchecked. And a rule by exclusion refuses POST /v0/books, which has answered 308 since with Python and takes a token, not a cookie. A list has a price too: it knows nothing of a route that someone adds later. The comment above the list says what to do then. A new route that reads the session cookie goes into it.

The check on /login has a cost, and it is chosen: a client that sends no 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 is 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

  1. Four tests in tests/test_attacks.py and one in tests/test_browser.py, the first two of them seen failing once.

  2. 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 in COOKIE_POSTS for you.

  3. 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 is looked up:

--- 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
  1. Row F12 of SECURITY.md, in your own words. Its Test cell names the four attack tests of this chapter. Its status is open: 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 stops them as it stops every other . Chapter 9 gives that one origin permission to read.

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 . The token of her is state["sessions"][jar[0]["value"]]["csrf"]. The form is the delete form of Lesson 5, posted with visit.visit(page, form, jar, send).

  1. In tests/test_attacks.py, below your test of Lesson 5, write four tests.

    • test_attack_token_alone_stops_the_neighbor(): first state["trusted_origins"].add(steps.NOTES). Post the form with the body b"id=1" from steps.NOTES. Assert that "cookie" is in the headers of the request, and that 1 in state["books"].

    • test_attack_origin_check_alone_stops_the_neighbor(): post the form with the body ("id=1&csrf=" + token).encode("utf-8") from steps.NOTES, and leave state["trusted_origins"] alone. Assert the same two things.

    • test_attack_a_get_cannot_delete_a_book(): follow a link from steps.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 from steps.OWN, and assert that 1 not in state["books"].

  2. In tests/test_browser.py, write test_samesite_alone_does_not_stop_the_neighbor(): post the form with the body b"id=1" from steps.NOTES and assert that "cookie" is in the headers of the request.

  3. In route in reading_server.py, make this the first statement after segments is set: when method == "POST", segments in COOKIE_POSTS, and origin_allowed(request, state["trusted_origins"]) is false, return json_error(403, "this request did not come from a page of this service"). Take the check out of the "/books/delete" branch.

  4. Run python -m pytest -q until it ends with 35 passed, then run python pl_sec.py cross-site-delete.

  5. In SECURITY.md, fill row F12. Its Test cell names test_attack_another_origin_cannot_delete_a_book and the three test_attack_ tests of this lesson. Its status is open: 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 whose 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 . Twenty-four GET and HEAD requests must change nothing. The checks of Lessons 6 and 7 run again with new values. Your seven tests of this chapter are selected by name. Each must pass against a server of the check’s own that has every defense, with a model whose rules are right. Each must fail against the server, or with the model, that has the one mistake the test is written for: no token check, no 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 is graded.