HTTPS, Certificates, and Security Headers · practice
One harden() for Every Response
You can read one browser instruction now, and your service sends none. This lesson adds four, through one
Add this test to tests/test_attacks.py, under the Chapter 10 comment at the end of the file:
def test_attack_every_response_says_nosniff():
"""Every response, an error included, must tell a browser to believe Content-Type."""
state = reading_server.sample_state()
about = reading_server.handle(helpers.make_request("GET", "/about"), state)
assert about["status"] == 200
assert about["headers"].get("x-content-type-options") == "nosniff"
missing = reading_server.handle(helpers.make_request("GET", "/nowhere"), state)
assert missing["status"] == 404
assert missing["headers"].get("x-content-type-options") == "nosniff"
headers = {"Authorization": helpers.basic("ana", "a wrong guess"), "Origin": steps.OWN}
refused = reading_server.handle(helpers.make_request("POST", "/login", headers=headers), state)
assert refused["status"] == 401
assert refused["headers"].get("x-content-type-options") == "nosniff"
It asks for a public route, a path that does not exist, and a login that fails. The first assert is its precondition: the public route answers 200. Run python -m pytest -q and watch it fail on the second assert. The rest of this lesson makes it pass.
Believe the label
A browser sometimes guesses what a response is from its bytes, whatever Content-Type says. That guessing is called sniffing. Your service gives it something to guess about. A cover is served as text/plain, and its text comes from whoever supplied the cover. Suppose a cover holds /covers/kindred.txt into your page. The policy of Lesson 4 allows it, because the file comes from the page’s own nosniff, a browser may run it.
X-Content-Type-Options: nosniff tells the browser to believe the label. With it, a browser refuses to run a script whose declared type is not a script type. The header costs nothing and fits every response, so every response gets it.
A cover is served as text/plain and holds JavaScript text. A page names it in a script tag. The response says nosniff. What does a browser do?
Stay on HTTPS
Strict-Transport-Security, HSTS for short, is a promise about the future: this host speaks HTTPS, for at least max-age seconds. A browser remembers it. From then on it turns every http:// link to this host into https:// before it sends anything. And when a
The header is sent over HTTPS only. Over plain harden() takes a https.
Two limits. The first visit is not protected, because the browser has nothing to remember yet. Browsers ship with preload preload in an HSTS header belongs to that process. And the supplied HSTS = "max-age=31536000", is one year without includeSubDomains. That word would bind every host under reading.example, and notes.reading.example is run by other people.
Why does harden() add strict-transport-security only when https is true?
A reader's browser has never contacted the site. Which request does HSTS not protect?
Say less to other sites
When a reader follows a link away from your page, the browser tells the next site where the reader came from, in a Referer header. Your service logs paths and takes a next parameter, so it tells the browser not to pass its Referrer-Policy: strict-origin-when-cross-origin. Another site then learns the origin and never the path. Current browsers behave this way by default. The header pins the behavior for older browsers.
Why not no-referrer, which says even less? Because that value also changes the Origin header. Under it a browser sends Origin: null with every form your own pages post: the delete form, and the forms for login, logout, and the page size. The check you wrote in Chapter 8 refuses null, and it should. So the stricter value would lock your own pages out.
Someone changes the policy to no-referrer, to say even less. What stops working?
One function, decided from the response
harden(response, *, https) gets a response and one fact about the connection. It has no request and no state, so every rule is decided from the response itself:
| Header | Value | When |
|---|---|---|
x-content-type-options | nosniff | always |
referrer-policy | strict-origin-when-cross-origin | always |
content-security-policy | PAGE_CSP | the media type is text/html |
cache-control | no-store, replacing any other value | the media type is text/html, or the response has a set-cookie header |
strict-transport-security | HSTS | https is true |
The media type is the part of content-type before any ;, stripped and in lower case. A response without content-type has none.
The no-store row is a safety net under a rule you already have. Since Chapter 4, a response that sets a harden() leaves every other cache-control and etag as the route made it: GET /about keeps max-age=60, and a token read keeps private, no-cache.
Your task
Write the test above and see it fail.
Write
hardeninreading_server.py.PAGE_CSPandHSTSare supplied near the top of the file. Return the response.Change the last line of
handle()toreturn harden(response, https=state["https"]).sample_state()now has an"https"key, and it isFalse: your server still speaks plain HTTP. A service behind a front server that endswould set the same flag from its configuration. Change the last line of
closing_error()toreturn harden(response, https=False). That function answers a request whose body cannot be read, such as a POST withoutContent-Length. Its answer is made beforehandle()runs, so it needs its own way throughharden(). It has no state to ask, so it passesFalse.
python -m pytest -q
All tests pass, the new one included. Then press Check my work. Every response your code makes now carries its instructions, and one of them, HSTS, has never been sent, because the server does not speak HTTPS yet. The project changes that.
Task
Add the browser instructions to every response, in reading_server.py, and prove one of them with a test.
In tests/test_attacks.py:
Write
test_attack_every_response_says_nosniff. It callsreading_server.handle()forGET /about, for a path that does not exist, and for a login with a wrong password, and asserts that each response hasx-content-type-optionsequal tonosniff. Read the header withresponse["headers"].get("x-content-type-options"), so that a missing header fails the assert instead of raising. Assert first that GET /aboutanswers 200.
In reading_server.py:
harden(response, *, https)changes the response’s headers and returns the response:always
x-content-type-options: nosniffandreferrer-policy: strict-origin-when-cross-origin;content-security-policy: PAGE_CSPwhen the media type ofcontent-typeistext/html, in any case and with or without; cache-control: no-store, replacing any other, when the media type is text/htmlor the response has aset-cookieheader;strict-transport-security: HSTSonly whenhttpsis true. It changes nothing else: not the status, the body, or any other header.
handle()ends withreturn harden(response, https=state["https"]), so every response passes through it, errors included.closing_error()ends withreturn harden(response, https=False).
Run python -m pytest -q in the
Then press Check my work. It calls harden with responses it builds itself: handle with users, books, and a clock you have not seen, with state["https"] both False and True, and sends the page’s own delete form as a browser would. It sends a POST without Content-Length through http_adapter in memory. It runs your test against a server that sends the header everywhere, where it must pass, and against a server whose error responses lack it, where it must fail. No check opens a connection. Nothing from your terminal is graded.