From 38dff06e11f87cb0f6b3a308aa3119ef3357b6c2 Mon Sep 17 00:00:00 2001 From: umpireboulianne-design Date: Sat, 24 Oct 2026 02:19:01 -0400 Subject: [PATCH] security(keeper): reject non-GET methods on status server with 405 and Allow: GET Fixes #543 --- services/keeper/README.md | 2 + services/keeper/src/status-server.test.ts | 54 ++++++++++++++++++++++- services/keeper/src/status-server.ts | 29 +++++++++++- 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/services/keeper/README.md b/services/keeper/README.md index 74ea2026..bc03cd52 100644 --- a/services/keeper/README.md +++ b/services/keeper/README.md @@ -37,6 +37,8 @@ The status API is served by `npm run serve` (or any process that calls `createSt | `GET` | `/healthz` | Liveness probe — cheap, suitable for load balancers | | `GET` | `/` | Service info + endpoint list | +All endpoints only accept `GET` requests. Any non-`GET` method (`POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, etc.) is rejected at the server boundary with HTTP `405 Method Not Allowed`, an `Allow: GET` response header, and a JSON `{ "error": "method not allowed", "method": "", "allow": ["GET"], "available": [...] }` body without invoking RPC or Drand readers. + ### Configuration | Env var | Default | Description | diff --git a/services/keeper/src/status-server.test.ts b/services/keeper/src/status-server.test.ts index fa841403..01a63cc7 100644 --- a/services/keeper/src/status-server.test.ts +++ b/services/keeper/src/status-server.test.ts @@ -88,10 +88,11 @@ function makeSource(overrides: Partial = {}): BuildStatusSour async function invokeHandler( source: BuildStatusSource, path: string, + method = "GET", ): Promise<{ status: number; headers: Record; body: unknown }> { const handler = createStatusHandler(source); const req = Object.assign(Readable.from([]), { - method: "GET", + method, url: path, headers: { host: "localhost" }, }); @@ -324,3 +325,54 @@ test("GET /status/health returns health shape", async () => { assert.ok(["ok", "degraded", "down"].includes(health.drand as string)); }); }); + +test("rejects non-GET methods with 405 and Allow: GET at the server boundary", async () => { + const source = makeSource({ + reader: { + getRound: async () => { + throw new Error("reader should not be called on non-GET requests"); + }, + getBidState: async () => { + throw new Error("reader should not be called on non-GET requests"); + }, + }, + }); + + for (const method of ["POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) { + const direct = await invokeHandler(source, "/status", method); + assert.equal(direct.status, 405); + assert.equal(direct.headers["allow"], "GET"); + assert.equal(direct.headers["cache-control"], "no-store"); + const directBody = direct.body as Record; + assert.equal(directBody.error, "method not allowed"); + assert.equal(directBody.method, method); + assert.deepEqual(directBody.allow, ["GET"]); + } + + await withServer(source, async (server) => { + const addr = server.address(); + assert.ok(addr && typeof addr === "object"); + const baseUrl = `http://127.0.0.1:${addr.port}`; + + for (const [method, path] of [ + ["POST", "/status"], + ["PUT", "/status/rounds/1"], + ["DELETE", "/healthz"], + ["PATCH", "/status/health"], + ["POST", "/"], + ] as const) { + const res = await fetch(`${baseUrl}${path}`, { + method, + headers: { "content-type": "application/json" }, + body: method === "DELETE" ? undefined : JSON.stringify({ probe: true }), + }); + assert.equal(res.status, 405, `expected 405 for ${method} ${path}`); + assert.equal(res.headers.get("allow"), "GET"); + assert.equal(res.headers.get("cache-control"), "no-store"); + const body = (await res.json()) as Record; + assert.equal(body.error, "method not allowed"); + assert.equal(body.method, method); + assert.deepEqual(body.allow, ["GET"]); + } + }); +}); diff --git a/services/keeper/src/status-server.ts b/services/keeper/src/status-server.ts index 1c658e7d..ffb67618 100644 --- a/services/keeper/src/status-server.ts +++ b/services/keeper/src/status-server.ts @@ -43,9 +43,13 @@ function send( res: http.ServerResponse, status: number, body: unknown, + extraHeaders?: Record, ): void { const payload = JSON.stringify(body, bigintReplacer, 2); - res.writeHead(status, JSON_CONTENT); + res.writeHead( + status, + extraHeaders ? { ...JSON_CONTENT, ...extraHeaders } : JSON_CONTENT, + ); res.end(payload); } @@ -188,7 +192,28 @@ export function createStatusHandler(config: StatusServerConfig): http.RequestLis const baseHost = req.headers.host ?? `${host}:${port}`; const url = new URL(req.url ?? "/", `http://${baseHost}`); - if (req.method === "GET" && url.pathname === "/healthz") { + if (req.method !== "GET") { + req.resume?.(); + send( + res, + 405, + { + error: "method not allowed", + method: req.method ?? "UNKNOWN", + allow: ["GET"], + available: [ + "GET /status", + "GET /status/rounds/:id", + "GET /healthz", + "GET /status/health", + ], + }, + { allow: "GET" }, + ); + return; + } + + if (url.pathname === "/healthz") { void fastHealth(url).then((r) => send(res, r.status, r.body)); return; }