Origins and Cross-Site Requests · practice
Forge a Request from a Neighboring Site
Lesson 4 ended with a form that a page on a neighboring host posts to your server. This lesson sends it. Run the scenario:
python pl_sec.py cross-site-delete
It starts your server on this computer and plays a browser with your mini_browser.py. Its first lines say that the names exist only in the model and that every byte goes to this computer. Ana logs in from the reading
--- a page on https://other.example posts the form: id=1 to /books/delete ---
cookie sent: no
origin: https://other.example
the server answered: 401 (pl_sec sees this; the page does not)
book ids left: 1, 2
--- a page on https://notes.reading.example posts the same form ---
cookie sent: yes
origin: https://notes.reading.example
the server answered: 303 (pl_sec sees this; the page does not)
book ids left: 2
The form from other.example left without Ana’s notes.reading.example carried the cookie. The server answered 303, which is what it answers its own page, and book 1 is gone. In the last part of the output the reading list’s own page posts its form, and that works too. Your server cannot tell the second form from the third.
Neither page saw a status. pl_sec.py prints it because it plays the browser. A page that posts a form never reads the answer.
Read visit.py
Open visit.py. It is supplied, and you never change it. visit(page_origin, wanted, jar, send) makes one request the way a browser would, and it asks your rules at each decision. Read it for five things:
What
wantedholds. It is a. "kind"is"form","link","image", or"fetch". Then come"url","method", and optionally"headers"and"body". The first half of therefuses what a kind cannot do. A form sends GET or POST, and the only header it can set is a form content type. Where your
cookies_to_sendis called. That happens near the end, withtop_leveltrue for a form and for a link. The cookies it returns become thecookieheader.Where
originis added. A few lines above that, on every request that is not a GET or a HEAD. Lesson 7 uses this header.What
sendis. It is a function that you pass in, as you passed functions around in Networks with Python. It carries one request to its server and returns the response.steps.send_to(state)builds one that callsreading_server.handle()in memory.What comes back. A dict again.
"requests"lists every request that left the browser."response"is what the page’s script may read. For a form that isNone, and"blocked"says why.
Skip the block marked Chapter 9.
In visit(page_origin, wanted, jar, send), what is send?
Chapter 4 already stopped one attacker
The first result of the scenario deserves a test. Open tests/test_browser.py. Every test of this lesson starts the same way: Ana logs in from the reading list’s own page, so that the jar holds her cookie.
state = reading_server.sample_state()
jar = []
send = steps.send_to(state)
wanted = {
"kind": "fetch",
"method": "POST",
"url": steps.OWN + "/login",
"headers": {"Authorization": helpers.basic("ana", "correct horse")},
}
result = visit.visit(steps.OWN, wanted, jar, send)
assert result["response"]["status"] == 200
assert len(jar) == 1
Then a page on another site posts the form:
form = {
"kind": "form",
"method": "POST",
"url": steps.OWN + "/books/delete",
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
"body": b"id=1",
}
result = visit.visit(steps.OTHER, form, jar, send)
Write test_samesite_stops_a_form_from_another_site from these two pieces, and end it with the assertion that "cookie" not in result["requests"][0]["headers"]. It passes on the first run. The SameSite=Lax flag from Chapter 4 keeps the cookie at home, and your model shows it.
In a real attack, Ana opens the other page while she is logged in. Here the test plays her part: it logs her in and then posts the form for her.
The neighbor
notes.reading.example is a host where readers publish pages of their own, so anyone can put a form there. It is another Lax lets the cookie travel, and Strict would too.
Write the attack test in tests/test_attacks.py and name it test_attack_another_origin_cannot_delete_a_book. Log Ana in, post the same form from steps.NOTES, and assert two things in this order. First, that "cookie" is in the headers of the request. Without that line the test could pass because the cookie stayed home. Second, that 1 in state["books"]. The test may read state. The forging page cannot.
Run everything:
python -m pytest -q
The last line starts with 1 failed, 29 passed, and the failure is your second assertion: book 1 is gone. The test is doing its job. An attack test fails while the attack works. The comment at the top of the file already lists this test as expected to fail until Lesson 6.
The attack is called
Why does the forgery work from notes.reading.example and not from other.example?
What was never at risk
The /books do not look at the cookie. They ask for a bearer token in an Authorization header. A form cannot set that header, and no browser attaches a token by itself. So the same page cannot forge DELETE /books/1. The forgery lives where a cookie is the credential.
Why can the same page not forge DELETE /books/1 on the JSON route?
Your task
Write the two tests, each below the Chapter 8 comment of its file:
test_samesite_stops_a_form_from_another_siteintests/test_browser.py. It passes.test_attack_another_origin_cannot_delete_a_bookintests/test_attacks.py. Its last assertion fails today. The next lesson is the first one that asks it to pass.
You change nothing in reading_server.py in this lesson. Both tests reach the code through its visit.visit(...) and reading_server.sample_state(), with no from ... import line.
Then press Check my work.
Task
Write two tests: one that shows SameSite=Lax at work, and one that forges a request from a neighboring host and fails.
Both tests start by logging ana in through the model: jar = [], send = steps.send_to(state) with state = reading_server.sample_state(), and visit.visit(steps.OWN, wanted, jar, send) with a wanted whose "kind" is "fetch", whose "method" is "POST", whose "url" is steps.OWN + "/login", and whose "headers" hold "Authorization": helpers.basic("ana", "correct horse"). Assert that result["response"]["status"] == 200.
Both then post the delete form with visit.visit(page, form, jar, send). The form is {"kind": "form", "method": "POST", "url": steps.OWN + "/books/delete", "headers": {"Content-Type": "application/x-www-form-urlencoded"}, "body": b"id=1"}.
In
tests/test_browser.py, below the Chapter 8 comment, writetest_samesite_stops_a_form_from_another_site(). Post the form fromsteps.OTHERand assert that"cookie" not in result["requests"][0]["headers"]. Runpython -m pytest -q tests/test_browser.py. The test passes.In
tests/test_attacks.py, below the Chapter 8 comment, writetest_attack_another_origin_cannot_delete_a_book(). Post the form fromsteps.NOTES. Assert that"cookie" in result["requests"][0]["headers"], and then that1 in state["books"]. Runpython -m pytest -q. This test fails at its last assertion: the book is gone. It first has to pass in the next lesson.
Change nothing in reading_server.py.
Then press Check my work. It selects the two tests by name and runs each one a few times, in memory. Your test of the model runs with a model whose rules are right, where it must pass, and with a model that sends a Lax visit.visit and to reach reading_server.handle through the