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 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 SIMPLE_METHODS, SIMPLE_HEADERS, and SIMPLE_CONTENT_TYPES.
A page may write a header name in any case, so compare
name.lower()withSIMPLE_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 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 "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
Build asked from nothing but the four 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:
The answer must give the page permission like any other response:
may_read(response, page_origin, credentials=False).Its status must be from 200 to 299.
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.Every name in
unsafe_header_names(headers)must be a member ofaccess-control-allow-headers, or the list holds*and the name is notauthorization. Header names compare without case, so lower each member first.
The supplied list_members(text) splits such a comma-separated
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 * 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
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)returnsTruewhenmethodis not inSIMPLE_METHODS, when the lower-case form of a header name is not inSIMPLE_HEADERS, or when acontent-typeheader’s part before the;, stripped and in lower case, is not inSIMPLE_CONTENT_TYPES. Otherwise it returnsFalse.preflight_request(url, page_origin, method, headers)returns{"method": "OPTIONS", "url": url, "headers": asked, "body": b""}.askedholds"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: noand no header of the real request. preflight_allows(response, page_origin, method, headers)returnsFalseunless 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 ofaccess-control-allow-methodsexactly as written, or thatholds *; and every name inunsafe_header_names(headers)is a member ofaccess-control-allow-headers, compared without case, or that list holds*and the name is notauthorization.
In reading_server.py:
preflight_response()returns status 204, an empty body,access-control-allow-methodsset toCORS_METHODS, andaccess-control-allow-headersset toCORS_HEADERS.In the
"/books"and"/books/{id}"branches ofroute(), answerOPTIONSwithpreflight_response()beforetoken_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, 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