0%

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 , to every response your code makes. As in every chapter, the attack comes first, as a test.

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 text, and a missed escape lets a script tag that names /covers/kindred.txt into your page. The policy of Lesson 4 allows it, because the file comes from the page’s own . Without 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 check fails for this host, the browser is expected to offer no way to click past the error. That makes the refusals you watched in Lesson 3 final.

The header is sent over HTTPS only. Over plain anyone on the path could add or remove it, so a browser ignores it there, and the standard (RFC 6797) says not to send it there. That is why harden() takes a https.

Two limits. The first visit is not protected, because the browser has nothing to remember yet. Browsers ship with preload of hosts for that case, and the word 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 on: 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:

HeaderValueWhen
x-content-type-optionsnosniffalways
referrer-policystrict-origin-when-cross-originalways
content-security-policyPAGE_CSPthe media type is text/html
cache-controlno-store, replacing any other valuethe media type is text/html, or the response has a set-cookie header
strict-transport-securityHSTShttps 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 is never stored, and the page shows one reader’s books and their form token. 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

  1. Write the test above and see it fail.

  2. Write harden in reading_server.py. PAGE_CSP and HSTS are supplied near the top of the file. Return the response.

  3. Change the last line of handle() to return harden(response, https=state["https"]). sample_state() now has an "https" key, and it is False: your server still speaks plain HTTP. A service behind a front server that ends would set the same flag from its configuration.

  4. Change the last line of closing_error() to return harden(response, https=False). That function answers a request whose body cannot be read, such as a POST without Content-Length. Its answer is made before handle() runs, so it needs its own way through harden(). It has no state to ask, so it passes False.

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 calls reading_server.handle() for GET /about, for a path that does not exist, and for a login with a wrong password, and asserts that each response has x-content-type-options equal to nosniff. Read the header with response["headers"].get("x-content-type-options"), so that a missing header fails the assert instead of raising . Assert first that GET /about answers 200.

In reading_server.py:

  • harden(response, *, https) changes the response’s headers and returns the response:

    • always x-content-type-options: nosniff and referrer-policy: strict-origin-when-cross-origin;

    • content-security-policy: PAGE_CSP when the media type of content-type is text/html, in any case and with or without ;

    • cache-control: no-store, replacing any other , when the media type is text/html or the response has a set-cookie header;

    • strict-transport-security: HSTS only when https is true. It changes nothing else: not the status, the body, or any other header.

  • handle() ends with return harden(response, https=state["https"]), so every response passes through it, errors included.

  • closing_error() ends with return harden(response, https=False).

Run python -m pytest -q in the : every test passes.

Then press Check my work. It calls harden with responses it builds itself: , a page, responses that set , and a response with no headers. It calls 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.