0%

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 ’s own page. Then two other pages post the delete form for book 1:

--- 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 . The server answered 401, and both books stayed. The same form from 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:

  1. What wanted holds. It is a . "kind" is "form", "link", "image", or "fetch". Then come "url", "method", and optionally "headers" and "body". The first half of the refuses what a kind cannot do. A form sends GET or POST, and the only header it can set is a form content type.

  2. Where your cookies_to_send is called. That happens near the end, with top_level true for a form and for a link. The cookies it returns become the cookie header.

  3. Where origin is added. A few lines above that, on every request that is not a GET or a HEAD. Lesson 7 uses this header.

  4. What send is. 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 calls reading_server.handle() in memory.

  5. 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 is None, 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 and the same site. 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 . The name dates from 2001, years before browsers drew the line between a site and an origin that you learned in Lesson 2, and this request never left the site. “Cross-origin” would describe it better, and the name stuck.

Why does the forgery work from notes.reading.example and not from other.example?

What was never at risk

The routes under /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_site in tests/test_browser.py. It passes.

  • test_attack_another_origin_cannot_delete_a_book in tests/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 , as in 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"}.

  1. In tests/test_browser.py, below the Chapter 8 comment, write test_samesite_stops_a_form_from_another_site(). Post the form from steps.OTHER and assert that "cookie" not in result["requests"][0]["headers"]. Run python -m pytest -q tests/test_browser.py. The test passes.

  2. In tests/test_attacks.py, below the Chapter 8 comment, write test_attack_another_origin_cannot_delete_a_book(). Post the form from steps.NOTES. Assert that "cookie" in result["requests"][0]["headers"], and then that 1 in state["books"]. Run python -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 along with a cross-site form post, where it must fail. Your attack test runs against a server with no defense, where it must fail, and against a server that refuses a form from another , where it must pass. It also has to call visit.visit and to reach reading_server.handle through the . Nothing from your is graded.