0%

CORS · practice

Answer from an Exact Allowlist

Lesson 1 traced the exchange on paper. Now you write both halves of it. The server’s half is cors_headers in reading_server.py, which decides what the response says. The browser’s half is may_read in mini_browser.py, which decides what the page gets. Check my work tries your server against a browser model of its own, and your model against answers of its own, so the two halves cannot agree on a mistake.

The server’s half

cors_headers(request, trusted) returns a of headers to add to a response. trusted is a set of . The state has such a set for this chapter, state["cors_origins"], and it holds one origin: https://app.reading.example.

The decision is the one origin_allowed made in Chapter 8. Read the values of the request’s Origin header. When there is exactly one, and the set holds it, the answer names that same origin:

access-control-allow-origin: https://app.reading.example

In every other case the answer has no such header. The server refuses nothing: it sends its usual response and leaves the permission out.

The comparison is values[0] in trusted, the whole text against the members of a set. Three shortcuts look like less work, and each gives something away:

  • Copying the Origin header into the answer. Every page that asks is told that it, by name, may read. The header then allows every origin there is.

  • A test with startswith or in on the text. https://app.reading.example.other.example starts with the trusted origin, and it belongs to whoever owns other.example. Chapter 8 showed these look-alikes.

  • Putting null in the set. A browser writes the origin of some pages, a page in a sandbox for one, as the text null. A set that holds "null" allows every such page, whoever made it.

A server copies the request's Origin into Access-Control-Allow-Origin on every response. Which pages may read its responses?

Vary: Origin

Until now /about gave everyone the same response. From this lesson on, its headers depend on a header of the request.

That matters to a cache. In with Python a cache stored a response under its . A cache that sits between many readers and your server, and stores /about that way, would keep the first answer it saw and hand it to everyone. If that first answer went to a page on another origin, it has no permission in it, and the front end is handed it and may not read. With two allowed origins, each could be handed an answer that names the other. The cache gives nobody a read they should not have: a page that is handed the front end’s answer still fails the browser’s check, because the header names another origin. What breaks is the readers you allowed.

One more prevents it:

vary: Origin

It tells a cache that the response depends on the request’s Origin header, so answers for different origins must be kept apart. The refusal depends on that header as much as the permission does. So cors_headers puts vary in its dict first and returns it in every case, also for a request with no Origin header at all.

Nothing in this course shows Vary at work, because the model has no cache. The reason given here is the whole . A server that already sets Vary for another reason would have to join the two values. This one has no other.

Why does the response carry Vary: Origin even when the origin is refused?

When a star is fine

Access-Control-Allow-Origin: * allows every origin. That is a fair answer for a response that is the same for everyone and needs no login: anyone could fetch it anyway. It is never right for a response behind a login. Browsers refuse a star whenever the page asked for its request to carry , and Lesson 5 comes back to that rule.

/about would qualify for a star. This service names the origin there as well, so that it has one rule to check and not two.

When is Access-Control-Allow-Origin: * acceptable?

The browser’s half

may_read(response, page_origin, *, credentials) is the check you ran in Lesson 1, as a that returns True or False. The supplied response_header(response, name) gives you one header of a response as text, or None.

  • No access-control-allow-origin: no.

  • The *, or exactly page_origin: yes.

  • Any other value: no. That includes a value with a slash at its end, two origins in one value, and the same letters in another case.

credentials says whether the page asked for its request to carry cookies. Lesson 5 writes the rule for that case. Until then, start the function with if credentials: return False. The model refuses what it does not yet understand, as load_keys did in Chapter 5.

Your task

In reading_server.py, write cors_headers. Then use it in route(). In the "/about" branch, in the case for GET and HEAD, add one line after the response is made:

            response = add_headers(response, cors_headers(request, state["cors_origins"]))

The supplied add_headers copies a dict of headers into a response and returns the response. Ask your function what it answers:

python -c "import reading_server; print(reading_server.cors_headers({'headers': {'origin': ['https://app.reading.example']}}, {'https://app.reading.example'}))"

It prints {'vary': 'Origin', 'access-control-allow-origin': 'https://app.reading.example'}. With any other origin in the request, it prints {'vary': 'Origin'}.

In mini_browser.py, write may_read.

Then write one test, test_a_page_without_permission_cannot_read, in tests/test_browser.py. It sends GET /about with the header Origin: https://app.reading.example straight to reading_server.handle, and hands the response to mini_browser.may_read twice: for a page on steps.APP, which may read it, and for a page on steps.OTHER, which may not. visit() cannot make this request for you yet. It needs a function that Lesson 5 writes.

python -m pytest -q
python mini_browser.py

The first ends in 36 passed. In the second table of python mini_browser.py, the first “may read” column now says True for the front end and False for the other two pages. The last column says False three times, and stays that way until Lesson 5.

Then press Check my work.

Task

Write the server’s answer and the browser’s check of it, and one test.

In reading_server.py:

  • cors_headers(request, trusted) returns a of headers. It always holds "vary": "Origin". When the request has exactly one origin and the set trusted holds that value, it also holds "access-control-allow-origin" with that same value. It never answers *, and it compares the whole value with the members of the set.

  • In the "/about" branch of route(), in the case for GET and HEAD: response = add_headers(response, cors_headers(request, state["cors_origins"])).

In mini_browser.py:

  • may_read(response, page_origin, *, credentials) returns False when credentials is true. Otherwise it reads response_header(response, "access-control-allow-origin") and returns True when the value is "*" or equals page_origin, and False for a missing header and for every other value.

In tests/test_browser.py, below the Chapter 9 comment, write test_a_page_without_permission_cannot_read. Build helpers.make_request("GET", "/about", headers={"Origin": steps.APP}), hand it to reading_server.handle(request, state) with state = reading_server.sample_state(), and assert that mini_browser.may_read(response, steps.APP, credentials=False) is True and that mini_browser.may_read(response, steps.OTHER, credentials=False) is False.

Run python -m pytest -q and python mini_browser.py.

Then press Check my work. It calls cors_headers with requests and a set of two you have not seen: both trusted origins, another site, a neighboring host, hosts that only look like a trusted one, another , the http form, the text null, two Origin headers, and none. It sends GET and HEAD /about to handle() from three origins and without one, and lets a browser model of its own judge the answers. It calls may_read with responses of its own: no header, the exact origin, a star, an allowed 404, and eight values that only look right, and twice with credentials=True. Last, it runs your test by name, once: it must pass, and it must call reading_server.handle and mini_browser.may_read through their . Nothing from your is graded.