0%

CORS · capstone

Capstone project: Project: CORS on Both Sides

The front end can now do its whole job, in four requests:

  1. POST /tokens with credentials, to get a token for ana’s .

  2. GET /books with the token, after a preflight.

  3. PUT /books/1 with the token and a body, after a preflight.

  4. GET /me with credentials, to show who is logged in.

Nothing yet says what a page on another cannot do. This project writes those tests, and it moves the server’s answer to one place.

Four tests

All four go into tests/test_attacks.py. Three of them start the same way, with ana logged in through the reading ’s own page:

    state = steps.keyed_state()
    jar = []
    send = ana_logs_in(state, jar)

After that, a page’s request is visit.visit(page_origin, wanted, jar, send), and result["response"] is what the page’s script may read, or None.

test_the_front_end_can_work plays the four requests above from steps.APP and asserts on what the page read each time: a token, a status of 200, two books, the user ana.

test_attack_another_origin_cannot_read_the_list fetches /books with ana’s token, and /me with credentials, from steps.NOTES and from steps.OTHER. Every response must be None. First it asserts that the same fetch of /books works from steps.APP, so that the test cannot pass against a server on which nobody can read.

test_attack_another_origin_cannot_get_a_token sends the POST /tokens of Lesson 5 from the two other origins. The page reads nothing, and that alone proves too little: a server could make a token and merely not show it. A page never sees an answer it may not read. A test may:

    result = visit.visit(steps.NOTES, wanted, jar, send)
    assert result["response"] is None
    assert "token" not in helpers.json_of(send(result["requests"][0]))

test_cors_does_not_stop_a_plain_client uses no browser at all. It hands GET /books with a token and no Origin header straight to reading_server.handle and asserts 200 and two books.

The tests pass as soon as you write them, because Lesson 5 left the server correct. See two of them fail once. Put return True as the first line of origin_allowed, or take and values[0] in trusted out of cors_headers, and run:

python -m pytest -q -k another_origin

Each edit gives 1 failed, 2 passed: the token test for the first, the list test for the second. Undo both. Check my work does the same with servers of its own that each make one mistake.

One place

Five lines in five branches now add the CORS answer, and some responses miss theirs. Ask for /about twice from the front end, the second time with the etag of the first answer:

python -c "
import sys
sys.path.insert(0, 'tests')
import helpers, reading_server
state = reading_server.sample_state()
headers = {'Origin': 'https://app.reading.example'}
first = reading_server.handle(helpers.make_request('GET', '/about', headers=headers), state)
headers['If-None-Match'] = first['headers']['etag']
again = reading_server.handle(helpers.make_request('GET', '/about', headers=headers), state)
print(again['status'], list(again['headers']))
"

It prints 304 ['etag', 'cache-control']. not_modified() copies those two headers and drops the rest, vary among them. A 304 must carry the Vary that its 200 would have carried, so that a cache keeps one origin’s answer apart from another’s.

The remedy is the one of Chapter 8’s project: one place. cors_rule(segments) names the answer a path gets:

PathRule
/about"public"
/books and /books/{id}"token"
/me and /tokens"session"
every other path, /books/delete among themNone

Then one block in route(), after the lines that replace a 200 with a 304 and before the no-store rule, applies it:

    rule = cors_rule(segments)
    if rule is not None:
        extra = cors_headers(request, state["trusted_origins"], credentials=rule == "session")
        for name in extra:
            response["headers"][name] = extra[name]

Remove the five add_headers lines of Lessons 2 to 5. Every response that reaches this block now leaves with the same answer: the 304, a 404, a 405, and the 401 for a missing token, which the front end can now read and explain to its user. One response of a CORS route never reaches it: the 403 of the Origin check on POST /tokens returns from the first lines of route(). It goes to an origin outside the set, or to a request without an Origin header, and neither would get a permission anyway. A path that gets None gets no CORS header at all. Run the python -c line again: the list ends in 'vary', 'access-control-allow-origin'.

What CORS did not do

Run python pl_sec.py cors and read its last request:

--- a client that is not a browser: GET /books with the token and no Origin header ---
  your server answered 200
        access-control-allow-origin: (not sent)
        vary: Origin
  books in the answer: 2. This client never looks at a CORS header.

Every Access-Control- header is addressed to a browser. Without them, a browser shows a page nothing from a simple request and never sends one that needs a preflight. That is real, and it is the browser’s doing. A client that is not a browser ignores all of it.

So CORS is not access control. It never stops a client that is not a browser. What protects the server is what you built before this chapter: the token check, the owner check, the Origin check, and the form token.

You remove every CORS header from the server. Which request does the server now refuse that it answered before?

Milestones

  1. The four tests, two of them seen failing once. python -m pytest -q ends with 41 passed.

  2. cors_rule and the block, with the five old lines gone. Still 41 passed.

  3. python pl_sec.py cors: the front end read 4 of 4 responses; pages on other origins read 0 of 4.

  4. Row F12 of SECURITY.md, extended in your own words for the reading half. It names the two new attack tests beside the four it has, and says closed in 9.6.

One step is not graded. The supplied cors_compare.py sends five requests to your handle() and to Starlette’s CORSMiddleware, a ready-made CORS answer from a Python web toolkit, inside one program, and prints both answers side by side. Differences are expected and are not errors in your code. Where Starlette is not installed, the file says so in one line and stops.

The model does not hide from a page. A browser shows a script on another origin only Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma, and whatever the server lists in Access-Control-Expose-Headers. Your tests read only bodies and statuses.

Every promise this service makes so far, from the Secure to the origin of a page, assumes HTTPS. Chapter 10 looks at what that assumption rests on.

Then press Check my work.

Task

Write four tests, put the server’s answer in one place, and close a finding.

  1. In tests/test_attacks.py, below the Chapter 9 comment, write four tests. The first three start with state = steps.keyed_state(), jar = [], and send = ana_logs_in(state, jar), and make every request of a page with visit.visit(page_origin, wanted, jar, send).

    • test_attack_another_origin_cannot_read_the_list(): with headers = {"Authorization": steps.bearer_for(state, "ana", "correct horse")}, fetch steps.OWN + "/books" from steps.APP and assert status 200. Then fetch it from steps.NOTES and steps.OTHER, and fetch steps.OWN + "/me" with "credentials": True from both, and assert that result["response"] is None each time.

    • test_attack_another_origin_cannot_get_a_token(): with wanted = {"kind": "fetch", "method": "POST", "url": steps.OWN + "/tokens", "credentials": True}, assert that the response to steps.APP holds a token. For steps.NOTES, assert that the response is None and that "token" is not in helpers.json_of(send(result["requests"][0])).

    • test_the_front_end_can_work(): from steps.APP, get a token from /tokens with credentials, fetch /books with it, change a book with PUT and a body, and fetch /me with credentials. Assert on each response the page read.

    • test_cors_does_not_stop_a_plain_client(): hand helpers.make_request("GET", "/books", headers=...) with a token and no Origin header to reading_server.handle(...), and assert status 200.

  2. In reading_server.py, write cors_rule(segments): "public" for ["about"], "session" for ["me"] and ["tokens"], "token" for ["books"] and for a of two that starts with "books" and does not end in "delete", and None for every other list.

  3. In route(), after the lines that replace a 200 with not_modified(response) and before the no-store rule, call cors_rule(segments). When the rule is not None, copy every header of cors_headers(request, state["trusted_origins"], credentials=rule == "session") into response["headers"]. Remove the add_headers calls of Lessons 2 to 5.

  4. Run python -m pytest -q until it ends with 41 passed, then run python pl_sec.py cors.

  5. In SECURITY.md, extend row F12 for the reading half. Add test_attack_another_origin_cannot_read_the_list and test_attack_another_origin_cannot_get_a_token to the tests it names, and set its status to closed in 9.6.

Then press Check my work. It calls cors_rule with fifteen lists of segments. It sends requests from the front end’s to handle(): a 401, a 404, three 405s, a 415, a HEAD, and two 304s must each carry the CORS answer, and six paths without a rule must carry none. It passes the bytes of a preflight through http_adapter in memory. It repeats the checks of Lessons 2, 4, and 5 for the model and the server. It runs your four tests, selected by name, against servers it puts in place of reading_server.handle, with a browser model of its own in place of visit.visit: each test must pass against a server that answers CORS as this chapter asks, and fail against a server with one mistake, which is telling every origin it may read, handing out a token whatever the Origin header says, sending no CORS header at all, or refusing every request without an Origin header. The two attack tests must also fail against the server that sends no CORS header. No test may open a connection. It reads row F12 of SECURITY.md; its wording is not judged. Nothing from your is graded.