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 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 state["https"] is False and no response has carried HSTS. This project puts
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 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
In
make_server, add a keyword-onlycontext=None. When a context is given, wrapserver.socketas shown above and store the result back inserver.socket.In
main(), add--certfileand--keyfileas described.Write three more tests in
tests/test_attacks.py:test_attack_page_refuses_inline_scriptlogs in withsteps.login_cookie(), asks forGET /with the, and asserts that the response’s content-security-policyhas adefault-srcand does not contain'unsafe-inline'.test_attack_hsts_only_over_httpsasserts thatGET /aboutcarries nostrict-transport-securitywhilestate["https"]isFalse, and carries it after the test setsstate["https"] = True.test_attack_client_context_verifiescallstls_contexts.client_context("certs/ca.pem")and asserts thatverify_modeisssl.CERT_REQUIREDand thatcheck_hostnameisTrue. The file already importssslandtls_contexts.
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
Run
python -m pytest -q. Then fill in row F13 ofSECURITY.md: name the four tests of this chapter in its Test column and writeclosed in 10.6in 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
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 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
In reading_server.py:
make_server(handle, state, *, timeout=1.0, context=None): whencontextis notNone, setserver.socket = context.wrap_socket(server.socket, server_side=True, do_handshake_on_connect=False). Without a context the server is as before.main()takes--certfileand--keyfile. With both, it callstls_contexts.server_context(args.certfile, args.keyfile), setsstate["https"] = True, and passescontext=...tomake_server. With neither,state["https"]staysFalseand no context is made. With only one of them, it callsparser.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-incarries a content-security-policywithdefault-srcand without'unsafe-inline'.test_attack_hsts_only_over_https:GET /aboutcarries nostrict-transport-securitywhilestate["https"]isFalse, and carries it when the flag isTrue.test_attack_client_context_verifies:tls_contexts.client_context("certs/ca.pem")hasverify_modeequal tossl.CERT_REQUIREDandcheck_hostnameequal toTrue.
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 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.