0%

CORS · practice

Answer the Preflight

The exchange at the end of Lesson 3 has three decisions on the browser’s side and one on the server’s. The browser decides whether to ask, builds the question, and reads the answer. The server answers the question. In this lesson you write all four: three in mini_browser.py, and one function and two branches in reading_server.py.

Draw the line

needs_preflight(method, headers) returns True when the browser must ask first. headers is a of the headers the page set, names to text. It is the function you read in Lesson 3, with two details added, and this chapter’s project supplies the three sets: SIMPLE_METHODS, SIMPLE_HEADERS, and SIMPLE_CONTENT_TYPES.

  • A page may write a header name in any case, so compare name.lower() with SIMPLE_HEADERS.

  • A content type may carry a , as in text/plain; charset=utf-8. The part that counts is the text before the ;, stripped and in lower case.

So Authorization crosses the line with any , and Content-Type: application/json does too. Accept: application/json does not: Accept is on the safelist, and only a content type is looked into. Test the method first, then walk through the headers, and return False when nothing crossed the line.

Ask

preflight_request(url, page_origin, method, headers) returns the question as a request dict:

{"method": "OPTIONS", "url": url, "headers": asked, "body": b""}

asked holds "origin" with the page’s and "access-control-request-method" with the method. When the request has headers outside the safelist, it also holds "access-control-request-headers": their names, joined with a comma. The supplied unsafe_header_names(headers) returns those names in lower case and sorted, by asking your needs_preflight about each header alone. When that is empty, the header is left out.

Build asked from nothing but the four . No goes in, and the Authorization header of the real request does not either. Only its name does.

The front end wants PUT /books/2 with Authorization and Content-Type: application/json. What does the preflight's Access-Control-Request-Headers hold?

Read the answer

preflight_allows(response, page_origin, method, headers) returns True when the answer lets the real request go. It makes four tests, in this order:

  1. The answer must give the page permission like any other response: may_read(response, page_origin, credentials=False).

  2. Its status must be from 200 to 299.

  3. The method must be a member of access-control-allow-methods, unless it is GET, HEAD, or POST, or the list holds *. The comparison is exact.

  4. Every name in unsafe_header_names(headers) must be a member of access-control-allow-headers, or the list holds * and the name is not authorization. Header names compare without case, so lower each member first.

The supplied list_members(text) splits such a comma-separated into its members, and gives an empty list for a header that is missing.

Each test alone can say no. An answer with perfect lists and a status of 404 is a no. So is a 204 that names another origin, or none. Return False at the first test that fails, and True only at the end.

A * in either list covers what is not named, with one : a * never covers authorization. That header has to be named.

The third test is exact for a reason. A browser writes six method names in upper case for you, DELETE, GET, HEAD, OPTIONS, POST, and PUT, and no others. So a page that asks for patch sends patch, and an answer that allows PATCH does not cover it.

A preflight answer has every right header and status 404. What does the browser do?

A preflight answer says Access-Control-Allow-Headers: *. The real request carries Authorization. Is it allowed?

Answer on the server

preflight_response() returns the answer: status 204, an empty body, and the two lists, taken from the supplied constants CORS_METHODS and CORS_HEADERS.

Where it is called matters more than what it holds. Both book branches of route() begin with token_claims() and a refusal, so today a preflight gets 401. OPTIONS has to come first, before either is reached:

    elif segments == ["books"]:
        if method == "OPTIONS":
            response = preflight_response()
        else:
            claims = token_claims(request, state)
            response = collection_refusal(method, claims)
        if response is None:

The "/books/{id}" branch gets the same shape around token_claims() and item_refusal().

The preflight answer still needs its access-control-allow-origin, and so does the answer to the real request that follows. One line as the last line of each of the two branches gives both:

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

For an origin outside the set, your server still answers 204 with both lists and vary. Only access-control-allow-origin is missing. That is enough: the browser’s first test fails, and the real request is never sent.

Answering OPTIONS without a token gives nothing away. The answer says which methods and headers exist, and the real request still needs its token.

Your task

Write the three functions marked for Lesson 4 in mini_browser.py, and preflight_response and the two branches in reading_server.py.

Then write test_a_delete_is_asked_about_first in tests/test_browser.py. With send = steps.send_to(state), build the question for a DELETE of steps.OWN + "/books/1" with an Authorization header from steps.APP, send it, and assert that preflight_allows says True. Do the same from steps.OTHER and assert False. The test calls your three functions directly, because visit() still needs one function from Lesson 5 before it can carry a request to another origin.

python -m pytest -q
python mini_browser.py

The first ends in 37 passed. The third table of the second now has answers:

request                                                     asks first       asks about
GET                                                         False            nothing is asked
POST with Content-Type: application/x-www-form-urlencoded   False            nothing is asked
POST with Content-Type: application/json                    True             content-type
GET with Authorization: Bearer a-token                      True             authorization
DELETE                                                      True             no header

Two things are left open. A browser may remember a preflight answer for a time that an Access-Control-Max-Age header can lengthen. The model remembers nothing, and your server does not set that header. And the answer now sits on the ordinary responses of the two book branches, while a 304 still leaves without it. Lesson 6 fixes that.

Then press Check my work.

Task

Write the three preflight rules of the model, the server’s answer to a preflight, and one test.

In mini_browser.py:

  • needs_preflight(method, headers) returns True when method is not in SIMPLE_METHODS, when the lower-case form of a header name is not in SIMPLE_HEADERS, or when a content-type header’s part before the ;, stripped and in lower case, is not in SIMPLE_CONTENT_TYPES. Otherwise it returns False.

  • preflight_request(url, page_origin, method, headers) returns {"method": "OPTIONS", "url": url, "headers": asked, "body": b""}. asked holds "origin" (the page’s ), "access-control-request-method" (the ), and, when unsafe_header_names(headers) is not empty, "access-control-request-headers" with those names joined by a comma. It holds nothing else: no and no header of the real request.

  • preflight_allows(response, page_origin, method, headers) returns False unless all four hold: may_read(response, page_origin, credentials=False); a status from 200 to 299; the method is GET, HEAD, or POST, or it is a member of access-control-allow-methods exactly as written, or that holds *; and every name in unsafe_header_names(headers) is a member of access-control-allow-headers, compared without case, or that list holds * and the name is not authorization.

In reading_server.py:

  • preflight_response() returns status 204, an empty body, access-control-allow-methods set to CORS_METHODS, and access-control-allow-headers set to CORS_HEADERS.

  • In the "/books" and "/books/{id}" branches of route(), answer OPTIONS with preflight_response() before token_claims() and the refusal are reached.

  • As the last line of each of those two branches: response = add_headers(response, cors_headers(request, state["cors_origins"])).

In tests/test_browser.py, write test_a_delete_is_asked_about_first. With state = reading_server.sample_state(), send = steps.send_to(state), url = steps.OWN + "/books/1", and headers = {"Authorization": "Bearer a-token"}: build mini_browser.preflight_request(url, steps.APP, "DELETE", headers), send it, and assert that mini_browser.preflight_allows(answer, steps.APP, "DELETE", headers) is True. Do the same for steps.OTHER and assert False.

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

Then press Check my work. It calls needs_preflight with nineteen requests you have not seen, in six groups: other methods, the three simple methods alone, header names outside the safelist, the safelisted names, , and the three form in mixed case with a . It reads the question preflight_request builds for a PUT with a token and JSON, and for a DELETE without headers. It calls preflight_allows with answers of its own: statuses from 199 to 404, a missing or other origin, a missing or lower-case method, a star in each list, and header names in another case. It sends OPTIONS to both book routes without a cookie and without a token, from the front end’s origin and from two others, and to /login. It reads a book with a token from two origins. A browser model of its own then sends a DELETE with a token through your server, once from each side. Last, it runs your test by name, once. Nothing from your is graded.