0%

HTTPS, Certificates, and Security Headers · capstone

Capstone project: Project: The Reading List over HTTPS

Row F13 of SECURITY.md is the last open row that this course closes. It has two halves. A client that does not verify the talks to anyone, and you closed that half with client_context(). The other half is the page: no response told a browser anything, and since the last lesson every response your code makes leaves through harden(). One branch of harden() has never run, though. Your server speaks plain , so state["https"] is False and no response has carried HSTS. This project puts on the server.

Wrap the listening socket

make_server builds an HTTPServer, and that server owns a listening , server.socket. To speak TLS, wrap that socket with a server context before the first connection arrives:

server.socket = context.wrap_socket(server.socket, server_side=True, do_handshake_on_connect=False)

Every connection the server accepts from then on is a TLS connection. server_side=True says which half of the handshake this end plays.

The third keeps your server bounded. By default the handshake runs the moment a connection is accepted, inside accept, where no timeout applies. A client that connects and then says nothing would hold the server there, and the limit on connections would never be reached. With do_handshake_on_connect=False the handshake happens on the first read instead, and http_adapter.py lets each read on a connection wait at most two seconds. The limit is for each read, not for the handshake as a whole: a client that sends a few bytes at a time can hold a connection longer.

Python 3.14 also has http.server.HTTPSServer, which wraps the socket for you and makes its own context. You do the wrapping yourself here so that the setting above is yours to choose.

Why does make_server pass do_handshake_on_connect=False?

Tell the service it is behind HTTPS

harden() cannot see the connection. It is told through state["https"]. So the place that decides whether the server speaks TLS must also set the flag, and that place is main(). Give it two options, --certfile and --keyfile. When both are given, main() builds a context with tls_contexts.server_context(), sets state["https"] = True, and passes the context to make_server. When neither is given, nothing changes. One without the other is a mistake in the command, and parser.error() reports it.

The server speaks TLS, and state["https"] was left False. What is missing from its responses?

Your task

  1. In make_server, add a keyword-only context=None. When a context is given, wrap server.socket as shown above and store the result back in server.socket.

  2. In main(), add --certfile and --keyfile as described.

  3. Write three more tests in tests/test_attacks.py:

    • test_attack_page_refuses_inline_script logs in with steps.login_cookie(), asks for GET / with the , and asserts that the response’s content-security-policy has a default-src and does not contain 'unsafe-inline'.

    • test_attack_hsts_only_over_https asserts that GET /about carries no strict-transport-security while state["https"] is False, and carries it after the test sets state["https"] = True.

    • test_attack_client_context_verifies calls tls_contexts.client_context("certs/ca.pem") and asserts that verify_mode is ssl.CERT_REQUIRED and that check_hostname is True. The file already imports ssl and tls_contexts.

  4. Run the service over TLS:

python pl_sec.py https

The helper builds a state, sets state["https"] itself, and serves it through your make_server with a context from your server_context():

pl_sec: https, your server on 127.0.0.1 (a port the system chose), speaking TLS with certs/reading.pem
pl_sec set state["https"] to True, as your main() must when it is given a certificate. The name reading.example exists only in the model.
--- GET /about over TLS, expecting the name reading.example ---
HTTP/1.1 200 OK
content-type: application/json
content-length: 41
etag: "a595067de2a7c59a4be181e441d808ef93b456aca1f6d76b8583cd58d30abe1b"
cache-control: max-age=60
vary: Origin
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
strict-transport-security: max-age=31536000
--- the same client, expecting the name other.example ---
refused before any request was sent: name
--- a client that speaks plain HTTP to the same port ---
the server closed the connection without an HTTP answer
--- result ---
the service answered over TLS, and its response carries strict-transport-security
serve() returned after 3 connections; the server is closed
pl_sec: every thread has stopped

The first block is your whole service answering inside TLS, with all its headers. The second is your own client refusing your own server, because it asked for a name the certificate does not . The third is a client that sent plain HTTP to a that expected a handshake.

  1. Run python -m pytest -q. Then fill in row F13 of SECURITY.md: name the four tests of this chapter in its Test column and write closed in 10.6 in its Status column.

A client sends a plain GET /about HTTP/1.1 to the TLS port. What does it get?

What this did not do

The run shows your server speaking TLS on this computer with test certificates. It shows nothing about deployment. This chapter did not issue, renew, or withdraw a certificate, and it did not run the service behind a front server.

Two more limits. When a client refuses the certificate, or speaks plain HTTP, http.server reports the failed handshake by printing about thirty lines of . The helper hides them. On your own computer you will see them, and the server keeps serving.

And “every response leaves through harden()” is true for every response your code makes. Some error pages are written by http.server itself, before your code sees the request: its 501 for a the adapter has no for, such as PATCH, its 400 for a request line it cannot read, and its answers to a request line or a header that is too long. They are HTML pages with none of these headers. And the 411 that closing_error() makes carries no HSTS over TLS: that function has no state to ask. That is one reason a real service sets such headers in its front server or its framework, where no response can pass them by.

Press Check my work. SECURITY.md now has F1 to F13 closed, and F14 open on purpose. In the next chapter nobody tells you where to look.

Task

Put on the reading , and close row F13.

In reading_server.py:

  • make_server(handle, state, *, timeout=1.0, context=None): when context is not None, set server.socket = context.wrap_socket(server.socket, server_side=True, do_handshake_on_connect=False). Without a context the server is as before.

  • main() takes --certfile and --keyfile. With both, it calls tls_contexts.server_context(args.certfile, args.keyfile), sets state["https"] = True, and passes context=... to make_server. With neither, state["https"] stays False and no context is made. With only one of them, it calls parser.error(...).

  • Everything Lesson 5 asked for still holds.

In tests/test_attacks.py, beside the test of Lesson 5, write:

  • test_attack_page_refuses_inline_script: the page of a logged-in carries a content-security-policy with default-src and without 'unsafe-inline'.

  • test_attack_hsts_only_over_https: GET /about carries no strict-transport-security while state["https"] is False, and carries it when the flag is True.

  • test_attack_client_context_verifies: tls_contexts.client_context("certs/ca.pem") has verify_mode equal to ssl.CERT_REQUIRED and check_hostname equal to True.

In SECURITY.md, fill in row F13: the names of the tests that prove it, and closed in 10.6.

Run python pl_sec.py https and python -m pytest -q in the .

Then press Check my work. It repeats the checks of Lesson 5. It calls make_server with stand-ins for the server class and the context, so that nothing listens and no handshake happens, and looks at how the was wrapped. It runs main() with stand-ins for make_server, serve, and server_context, with and without the two options. It checks the caching values of POST /tokens, GET /me, and a token read. It runs your four tests against a correct server and context, where they must pass, and each against the mistake it must notice, where it must fail. It reads row F13. No check opens a connection. Nothing from your terminal is graded.