From 24df541b468b6076172dde19608d450bdfd955c8 Mon Sep 17 00:00:00 2001 From: Really Him Date: Sat, 29 Aug 2026 11:48:58 -0400 Subject: [PATCH 1/3] feat: mirror GET for HEAD requests and add security headers HEAD requests to any read endpoint now return the same status and headers as GET with no body, per RFC 9110, implemented centrally in the worker front door. All responses now carry Strict-Transport-Security (max-age=86400, to be raised after burn-in) and X-Content-Type-Options: nosniff, in the worker and the Express app. The method allowlist and HEAD decision are recorded in ADR 0002. Co-Authored-By: Claude Fable 5 --- cloudflare/policychecks-worker.ts | 198 ++++++++++-------- .../0002-http-method-allowlist-and-head.md | 28 +++ src/server/http-app.ts | 7 + test/cloudflare-worker.test.ts | 42 ++++ test/routes.test.ts | 18 ++ 5 files changed, 201 insertions(+), 92 deletions(-) create mode 100644 docs/adr/0002-http-method-allowlist-and-head.md diff --git a/cloudflare/policychecks-worker.ts b/cloudflare/policychecks-worker.ts index 53ef0e4..6e1b4d9 100644 --- a/cloudflare/policychecks-worker.ts +++ b/cloudflare/policychecks-worker.ts @@ -42,119 +42,133 @@ const maxWebhookBodyBytes = 256 * 1024; let runtimeCache: Runtime | undefined; let runtimeKey: string | undefined; +// Raise max-age to 31536000 after a burn-in period on the live service. +const securityHeaders: Record = { + "Strict-Transport-Security": "max-age=86400", + "X-Content-Type-Options": "nosniff" +}; + export default { async fetch(request: Request, env: WorkerEnv): Promise { - try { - const url = new URL(request.url); - const { pathname } = url; - - if (pathname === "/healthz" && request.method === "HEAD") { - return new Response(null, { - status: 200, - headers: { - "Content-Type": "application/json; charset=utf-8" - } - }); - } - - if (pathname === "/healthz" && request.method === "GET") { - return json({ - ok: true - }); - } + // HEAD mirrors GET (RFC 9110): evaluate as GET, respond without a body. + // See docs/adr/0002-http-method-allowlist-and-head.md. + const isHead = request.method === "HEAD"; + const effectiveRequest = isHead + ? new Request(request.url, { method: "GET", headers: request.headers }) + : request; + const response = await handleRequest(effectiveRequest, env); + const decorated = new Response(isHead ? null : response.body, response); + + for (const [name, value] of Object.entries(securityHeaders)) { + decorated.headers.set(name, value); + } - if (request.method === "POST" && pathname === "/github/webhook") { - return handleWebhook(request, getWebhookSecret(env)); - } + return decorated; + } +}; - if (request.method !== "GET") { - return json( - { - error: "not_found" - }, - 404 - ); - } +async function handleRequest(request: Request, env: WorkerEnv): Promise { + try { + const url = new URL(request.url); + const { pathname } = url; - const route = parsePath(pathname); + if (pathname === "/healthz" && request.method === "GET") { + return json({ + ok: true + }); + } - if (route.kind === "not_found") { - return json( - { - error: "not_found" - }, - 404 - ); - } + if (request.method === "POST" && pathname === "/github/webhook") { + return handleWebhook(request, getWebhookSecret(env)); + } - if (route.kind === "info") { - const runtime = getRuntime(env); - const badges = await runtime.badgeService.evaluateMany( - badgeDefinitions, - route.owner, - route.repo - ); - - return json( - { - owner: route.owner, - repo: route.repo, - badges - }, - 200, - { - "Cache-Control": cacheControl - } - ); - } + if (request.method !== "GET") { + return json( + { + error: "not_found" + }, + 404 + ); + } - const definition = getBadgeDefinition(route.badgeId); + const route = parsePath(pathname); - if (definition === undefined) { - return json( - { - error: "unsupported_badge", - badgeId: route.badgeId - }, - 404 - ); - } + if (route.kind === "not_found") { + return json( + { + error: "not_found" + }, + 404 + ); + } + if (route.kind === "info") { const runtime = getRuntime(env); - const result = await runtime.badgeService.evaluate(definition, route.owner, route.repo); + const badges = await runtime.badgeService.evaluateMany( + badgeDefinitions, + route.owner, + route.repo + ); - if (route.kind === "json") { - return json(toShieldsJson(definition, result), 200, { + return json( + { + owner: route.owner, + repo: route.repo, + badges + }, + 200, + { "Cache-Control": cacheControl - }); - } + } + ); + } - if (route.kind === "details") { - return json(toDetailsJson(result), 200, { - "Cache-Control": cacheControl - }); - } + const definition = getBadgeDefinition(route.badgeId); - const svg = renderBadgeSvg(definition, result); - return new Response(svg, { - status: 200, - headers: { - "Content-Type": "image/svg+xml; charset=utf-8", - "Cache-Control": cacheControl - } - }); - } catch (error) { - console.error(error); + if (definition === undefined) { return json( { - error: "internal_error", - message: "The request failed before the badge could be evaluated." + error: "unsupported_badge", + badgeId: route.badgeId }, - 500 + 404 ); } + + const runtime = getRuntime(env); + const result = await runtime.badgeService.evaluate(definition, route.owner, route.repo); + + if (route.kind === "json") { + return json(toShieldsJson(definition, result), 200, { + "Cache-Control": cacheControl + }); + } + + if (route.kind === "details") { + return json(toDetailsJson(result), 200, { + "Cache-Control": cacheControl + }); + } + + const svg = renderBadgeSvg(definition, result); + return new Response(svg, { + status: 200, + headers: { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": cacheControl + } + }); + } catch (error) { + console.error(error); + return json( + { + error: "internal_error", + message: "The request failed before the badge could be evaluated." + }, + 500 + ); } -}; +} type ParsedPath = | { diff --git a/docs/adr/0002-http-method-allowlist-and-head.md b/docs/adr/0002-http-method-allowlist-and-head.md new file mode 100644 index 0000000..dd2d691 --- /dev/null +++ b/docs/adr/0002-http-method-allowlist-and-head.md @@ -0,0 +1,28 @@ +# ADR 0002: HTTP method allowlist and HEAD handling + +## Status + +Accepted + +## Date + +2026-08-29 + +## Context + +The Cloudflare worker front door default-denies HTTP methods: GET for the read endpoints (badges, `info.json`, `/healthz`) and POST for the GitHub webhook only. Historically this allowlist had a hand-written HEAD carve-out for `/healthz` alone, so HEAD requests to badge endpoints returned 404 while GET on the same URL returned 200. + +That divergence violates RFC 9110, which requires HEAD support wherever GET is supported, and it confuses HEAD-probing consumers such as link checkers, badge caches, and uptime monitors. + +Blocking HEAD also provides no information protection. The badge endpoints are public and return full results to any GET request, so a prober gains nothing from HEAD being closed. The service's actual anti-enumeration mechanism is collapsing ambiguous, unauthorized, and failed evaluations into the single `unknown` result. + +## Decision + +- The worker keeps a strict default-deny method allowlist: GET and HEAD for read endpoints, POST for the webhook route only. All other methods receive 404. +- HEAD mirrors GET everywhere: identical status and headers, no body. This is implemented once in the worker front door (HEAD requests are evaluated as GET and the response body is stripped). The Express app inherits HEAD-for-GET behavior from Express itself. + +## Consequences + +- Method behavior conforms to RFC 9110; HEAD probes agree with GET. +- The allowlist's rationale is recorded: it is a fail-safe default, not an information-hiding mechanism. Disclosure control is unaffected by HTTP method handling. +- A HEAD request costs the same as a GET internally (the evaluation runs and is cached); only the response body transfer is saved. diff --git a/src/server/http-app.ts b/src/server/http-app.ts index 585d45b..3757563 100644 --- a/src/server/http-app.ts +++ b/src/server/http-app.ts @@ -8,6 +8,13 @@ export function createHttpApp(badgeService: BadgeEvaluator, webhookRouter?: Rout const app = express(); app.disable("x-powered-by"); + app.use((_request, response, next) => { + // Raise max-age to 31536000 after a burn-in period on the live service. + response.setHeader("Strict-Transport-Security", "max-age=86400"); + response.setHeader("X-Content-Type-Options", "nosniff"); + next(); + }); + app.get("/healthz", (_request, response) => { response.json({ ok: true diff --git a/test/cloudflare-worker.test.ts b/test/cloudflare-worker.test.ts index 4b97887..e379f49 100644 --- a/test/cloudflare-worker.test.ts +++ b/test/cloudflare-worker.test.ts @@ -31,4 +31,46 @@ describe("Cloudflare Worker routes", () => { expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8"); expect(await response.text()).toBe(""); }); + + it("mirrors GET status and headers for HEAD on unknown paths", async () => { + const [head, get] = await Promise.all([ + worker.fetch(new Request("https://policychecks.example.test/nope", { method: "HEAD" }), env), + worker.fetch(new Request("https://policychecks.example.test/nope"), env) + ]); + + expect(head.status).toBe(get.status); + expect(head.headers.get("content-type")).toBe(get.headers.get("content-type")); + expect(await head.text()).toBe(""); + }); + + it("mirrors GET for HEAD on an unsupported badge id", async () => { + const response = await worker.fetch( + new Request("https://policychecks.example.test/github/OWNER/REPO/not-a-real-badge.svg", { + method: "HEAD" + }), + env + ); + + expect(response.status).toBe(404); + expect(await response.text()).toBe(""); + }); + + it("rejects methods outside the allowlist", async () => { + const response = await worker.fetch( + new Request("https://policychecks.example.test/healthz", { method: "PUT" }), + env + ); + + expect(response.status).toBe(404); + }); + + it("sets security headers on every response", async () => { + const response = await worker.fetch( + new Request("https://policychecks.example.test/healthz"), + env + ); + + expect(response.headers.get("strict-transport-security")).toBe("max-age=86400"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + }); }); diff --git a/test/routes.test.ts b/test/routes.test.ts index d109261..10356d6 100644 --- a/test/routes.test.ts +++ b/test/routes.test.ts @@ -18,6 +18,24 @@ describe("badge routes", () => { }); }); + it("answers HEAD requests on GET routes without a body", async () => { + const app = createHttpApp(serviceReturning("enabled")); + + const response = await request(app).head("/healthz").expect(200); + + expect(response.text).toBeFalsy(); + }); + + it("sets security headers on responses", async () => { + const app = createHttpApp(serviceReturning("enabled")); + + await request(app) + .get("/healthz") + .expect(200) + .expect("Strict-Transport-Security", "max-age=86400") + .expect("X-Content-Type-Options", "nosniff"); + }); + it("mounts an optional webhook router before badge routes", async () => { const webhookRouter = Router(); webhookRouter.get("/github/webhook-test", (_request, response) => { From b99542ddfb4729daa3d8fdf264860afdd643ede7 Mon Sep 17 00:00:00 2001 From: Really Him Date: Sat, 29 Aug 2026 11:49:20 -0400 Subject: [PATCH 2/3] docs(adr): record partial adoption and descoping of ADR 0001 The audit-language reframing (neutral info.json) was adopted; the README-presence publication check and aggregate-endpoint removal were never implemented and are no longer planned. The addendum records the operating model: GitHub App installation constitutes the maintainer's consent to public disclosure of all supported badge results. Co-Authored-By: Claude Fable 5 --- docs/adr/0001-badge-publication-consent.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/adr/0001-badge-publication-consent.md b/docs/adr/0001-badge-publication-consent.md index e6453b3..d9cc016 100644 --- a/docs/adr/0001-badge-publication-consent.md +++ b/docs/adr/0001-badge-publication-consent.md @@ -2,7 +2,7 @@ ## Status -Accepted +Superseded in part (2026-08-29) — see Addendum. The reframing away from audit language was adopted; the README-presence publication check and the removal of the aggregate endpoint were not implemented and will not be. ## Date @@ -353,3 +353,13 @@ This keeps PolicyChecks closest to its core value: a small public badge service Tokenized badge URLs remain the leading alternative if private repository support or non-README publication becomes important enough to justify the extra setup and authorization surface. If schedule pressure requires a smaller immediate change, remove `info.json` first. The current all-badges-public posture should not be treated as the intended long-term model. + +## Addendum (2026-08-29) + +This decision was adopted in part and deliberately descoped in part. The record above is preserved as written; this addendum states what actually happened. + +Adopted: the reframing away from audit and security language. The aggregate endpoint is presented as `info.json` — a neutral informational surface — rather than as an audit or compliance report, and product language follows that model. + +Not implemented, and no longer planned: the README-presence publication check and the removal of the public aggregate `info.json` endpoint. The README verification model was judged too complex for a v1 service without users — clever, but an overcomplication at current scale. + +The operating model is therefore: installing the GitHub App constitutes the maintainer's consent to public disclosure of all supported badge results for that repository, including the aggregate `info.json` endpoint (see the "explicit disclosure" framing in the read-only surface decision). Ambiguous, unauthorized, and failed evaluations collapse to `unknown`. Selective publication (README-presence or tokenized URLs) remains a documented alternative if adoption ever justifies it. From 658aec0a5cb138a13d58d6c5f275f262a84a59a2 Mon Sep 17 00:00:00 2001 From: Really Him Date: Sat, 29 Aug 2026 11:52:17 -0400 Subject: [PATCH 3/3] style: drop ADR reference from code comment Co-Authored-By: Claude Fable 5 --- cloudflare/policychecks-worker.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/cloudflare/policychecks-worker.ts b/cloudflare/policychecks-worker.ts index 6e1b4d9..27c4c32 100644 --- a/cloudflare/policychecks-worker.ts +++ b/cloudflare/policychecks-worker.ts @@ -51,7 +51,6 @@ const securityHeaders: Record = { export default { async fetch(request: Request, env: WorkerEnv): Promise { // HEAD mirrors GET (RFC 9110): evaluate as GET, respond without a body. - // See docs/adr/0002-http-method-allowlist-and-head.md. const isHead = request.method === "HEAD"; const effectiveRequest = isHead ? new Request(request.url, { method: "GET", headers: request.headers })