Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 105 additions & 92 deletions cloudflare/policychecks-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,119 +42,132 @@ 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<string, string> = {
"Strict-Transport-Security": "max-age=86400",
"X-Content-Type-Options": "nosniff"
};

export default {
async fetch(request: Request, env: WorkerEnv): Promise<Response> {
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.
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<Response> {
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 =
| {
Expand Down
12 changes: 11 additions & 1 deletion docs/adr/0001-badge-publication-consent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
28 changes: 28 additions & 0 deletions docs/adr/0002-http-method-allowlist-and-head.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions src/server/http-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions test/cloudflare-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
18 changes: 18 additions & 0 deletions test/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down