From 57ac2095c0cb53366243d05982eb736314cb42ce Mon Sep 17 00:00:00 2001 From: mrwulf Date: Sat, 22 Aug 2026 14:59:54 +0200 Subject: [PATCH] =?UTF-8?q?web:=20HEAD=20support=20=E2=80=94=20link=20chec?= =?UTF-8?q?kers=20and=20unfurlers=20get=20200+headers,=20not=20501?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- provider/src/pacta_provider/web.py | 20 +++++++++++++++----- tests/test_web_and_witness.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/provider/src/pacta_provider/web.py b/provider/src/pacta_provider/web.py index 4d7a344..f5e98d6 100644 --- a/provider/src/pacta_provider/web.py +++ b/provider/src/pacta_provider/web.py @@ -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 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: parsed = urlparse(self.path) 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-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self._body(body) elif route in ("/log-public-key", "/log-slhdsa-public-key"): # TOFU mitigation depends on the key being published in two # 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-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self._body(body) elif route == "/openapi.json": self._send(200, _openapi_document(base)) 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("X-Robots-Tag", "noindex, nofollow") self.end_headers() - self.wfile.write(body) + self._body(body) return True 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("Cache-Control", "no-store") self.end_headers() - self.wfile.write(body) + self._body(body) def _send_html(self, html: str) -> None: 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-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self._body(body) def log_message(self, fmt: str, *args: Any) -> None: # quiet by default pass diff --git a/tests/test_web_and_witness.py b/tests/test_web_and_witness.py index 5426467..d14de71 100644 --- a/tests/test_web_and_witness.py +++ b/tests/test_web_and_witness.py @@ -223,3 +223,33 @@ def test_openapi_document_served_and_valid(): assert doc["openapi"].startswith("3.") assert "/v1/sth" in doc["paths"] and "/log-public-key" in doc["paths"] _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()