web: HEAD support — link checkers and unfurlers get 200+headers, not 501

Found while verifying what /paper serves: the stdlib handler only
implemented do_GET, so every HEAD probe (mail clients, chat unfurlers,
link checkers — exactly the tools that touch the links we mail around)
got 501. do_HEAD now routes like GET with the body suppressed; all five
body writes go through one guard; regression test asserts HEAD returns
200, correct Content-Type, nonzero Content-Length, empty body.
This commit is contained in:
mrwulf 2026-08-22 14:59:54 +02:00
parent 03de38eaac
commit 57ac2095c0
2 changed files with 45 additions and 5 deletions

View file

@ -67,6 +67,16 @@ def make_handler(log: TransparencyLog, base_path: str, docs_html: str, paper_pdf
except Exception as exc: # noqa: BLE001 - the service must not die on a bad request except Exception as exc: # noqa: BLE001 - the service must not die on a bad request
self._send(500, {"error": f"internal error: {type(exc).__name__}"}) self._send(500, {"error": f"internal error: {type(exc).__name__}"})
def do_HEAD(self) -> None: # noqa: N802 - link checkers and mail/chat
# unfurlers probe with HEAD; answer with the same headers as GET
# and no body (a 501 here makes every link look broken to them).
self._head_only = True
self.do_GET()
def _body(self, body: bytes) -> None:
if not getattr(self, "_head_only", False):
self.wfile.write(body)
def _route(self) -> None: def _route(self) -> None:
parsed = urlparse(self.path) parsed = urlparse(self.path)
path = parsed.path.rstrip("/") path = parsed.path.rstrip("/")
@ -91,7 +101,7 @@ def make_handler(log: TransparencyLog, base_path: str, docs_html: str, paper_pdf
self.send_header("Content-Disposition", 'inline; filename="ltl.pdf"') self.send_header("Content-Disposition", 'inline; filename="ltl.pdf"')
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self._body(body)
elif route in ("/log-public-key", "/log-slhdsa-public-key"): elif route in ("/log-public-key", "/log-slhdsa-public-key"):
# TOFU mitigation depends on the key being published in two # TOFU mitigation depends on the key being published in two
# independent locations; this is the site's copy (the mirror # independent locations; this is the site's copy (the mirror
@ -110,7 +120,7 @@ def make_handler(log: TransparencyLog, base_path: str, docs_html: str, paper_pdf
self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self._body(body)
elif route == "/openapi.json": elif route == "/openapi.json":
self._send(200, _openapi_document(base)) self._send(200, _openapi_document(base))
elif route == "/healthz": elif route == "/healthz":
@ -222,7 +232,7 @@ def make_handler(log: TransparencyLog, base_path: str, docs_html: str, paper_pdf
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.send_header("X-Robots-Tag", "noindex, nofollow") self.send_header("X-Robots-Tag", "noindex, nofollow")
self.end_headers() self.end_headers()
self.wfile.write(body) self._body(body)
return True return True
def _send(self, code: int, payload: dict[str, Any], code_if_error: int | None = None) -> None: def _send(self, code: int, payload: dict[str, Any], code_if_error: int | None = None) -> None:
@ -234,7 +244,7 @@ def make_handler(log: TransparencyLog, base_path: str, docs_html: str, paper_pdf
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.end_headers() self.end_headers()
self.wfile.write(body) self._body(body)
def _send_html(self, html: str) -> None: def _send_html(self, html: str) -> None:
body = html.encode("utf-8") body = html.encode("utf-8")
@ -242,7 +252,7 @@ def make_handler(log: TransparencyLog, base_path: str, docs_html: str, paper_pdf
self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self._body(body)
def log_message(self, fmt: str, *args: Any) -> None: # quiet by default def log_message(self, fmt: str, *args: Any) -> None: # quiet by default
pass pass

View file

@ -223,3 +223,33 @@ def test_openapi_document_served_and_valid():
assert doc["openapi"].startswith("3.") assert doc["openapi"].startswith("3.")
assert "/v1/sth" in doc["paths"] and "/log-public-key" in doc["paths"] assert "/v1/sth" in doc["paths"] and "/log-public-key" in doc["paths"]
_json.dumps(doc) # serializable _json.dumps(doc) # serializable
def test_head_requests_answer_like_get_without_body(tmp_path):
# Link checkers and mail/chat unfurlers probe with HEAD; a 501 made
# /paper look broken to them (found 2026-08-22 while verifying what
# the paper link serves).
import http.client
import shutil
_make_log(tmp_path)
shutil.copy2(tmp_path / "k.pub", tmp_path / "log" / "provider.ed25519.pub")
server = serve(str(tmp_path / "log"), port=0)
port = server.server_address[1]
import threading
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
for route, ctype in [("/", "text/html"), ("/v1/sth", "application/json"),
("/log-public-key", "text/plain")]:
conn.request("HEAD", route)
r = conn.getresponse()
body = r.read()
assert r.status == 200, (route, r.status)
assert ctype in r.getheader("Content-Type", ""), route
assert body == b"", (route, len(body))
assert int(r.getheader("Content-Length", "0")) > 0, route
finally:
server.shutdown()