From eca2ebab04bd50aa8f389026d3e9cd1708a16bd9 Mon Sep 17 00:00:00 2001 From: umpireboulianne-design Date: Sat, 24 Oct 2026 03:07:45 -0400 Subject: [PATCH] security(keeper): add safe response headers to keeper status Fixes #542 --- services/keeper/README.md | 9 ++++++ services/keeper/src/status-server.test.ts | 39 ++++++++++++++++++++++- services/keeper/src/status-server.ts | 10 +++--- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/services/keeper/README.md b/services/keeper/README.md index 74ea2026..d7ad979f 100644 --- a/services/keeper/README.md +++ b/services/keeper/README.md @@ -91,6 +91,15 @@ Response shape (typed in `@sub-rosa/sdk` as `KeeperStatusResponse`): } ``` +### Response headers + +Every response from the status server (`200`, `400`, `404`, `500`, `503`) sets explicit security and caching headers: + +- `Content-Type: application/json; charset=utf-8` +- `Cache-Control: no-store` +- `X-Content-Type-Options: nosniff` +- `X-Frame-Options: DENY` + ### Round status fields - `status`: on-chain lifecycle (`Unknown | Open | Revealing | Cleared | Settled | Voided | NotFound`). diff --git a/services/keeper/src/status-server.test.ts b/services/keeper/src/status-server.test.ts index fa841403..c5598cff 100644 --- a/services/keeper/src/status-server.test.ts +++ b/services/keeper/src/status-server.test.ts @@ -6,7 +6,11 @@ import { createFakeTime } from "@sub-rosa/time"; import http from "node:http"; import { Readable } from "node:stream"; -import { createStatusHandler, createStatusServer } from "./status-server.js"; +import { + STATUS_RESPONSE_HEADERS, + createStatusHandler, + createStatusServer, +} from "./status-server.js"; import type { BuildStatusSource } from "./status.js"; import type { WatchedRound } from "./store.js"; @@ -324,3 +328,36 @@ test("GET /status/health returns health shape", async () => { assert.ok(["ok", "degraded", "down"].includes(health.drand as string)); }); }); + +test("sets explicit content-type, no-store, nosniff, and frame-denial headers on all status responses", async () => { + assert.deepEqual(STATUS_RESPONSE_HEADERS, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + }); + + const paths = ["/", "/status", "/status/rounds/1", "/status/rounds/999", "/status/health", "/healthz", "/unknown"]; + const source = makeSource(); + + for (const path of paths) { + const unitRes = await invokeHandler(source, path); + assert.equal(unitRes.headers["content-type"], "application/json; charset=utf-8", `unit content-type for ${path}`); + assert.equal(unitRes.headers["cache-control"], "no-store", `unit cache-control for ${path}`); + assert.equal(unitRes.headers["x-content-type-options"], "nosniff", `unit x-content-type-options for ${path}`); + assert.equal(unitRes.headers["x-frame-options"], "DENY", `unit x-frame-options for ${path}`); + } + + await withServer(source, async (server) => { + const addr = server.address(); + assert.ok(addr && typeof addr === "object"); + for (const path of paths) { + const res = await fetch(`http://127.0.0.1:${addr.port}${path}`); + assert.equal(res.headers.get("content-type"), "application/json; charset=utf-8", `http content-type for ${path}`); + assert.equal(res.headers.get("cache-control"), "no-store", `http cache-control for ${path}`); + assert.equal(res.headers.get("x-content-type-options"), "nosniff", `http x-content-type-options for ${path}`); + assert.equal(res.headers.get("x-frame-options"), "DENY", `http x-frame-options for ${path}`); + } + }); +}); + diff --git a/services/keeper/src/status-server.ts b/services/keeper/src/status-server.ts index 1c658e7d..8c915412 100644 --- a/services/keeper/src/status-server.ts +++ b/services/keeper/src/status-server.ts @@ -34,10 +34,12 @@ interface Route { ) => Promise<{ status: number; body: unknown }>; } -const JSON_CONTENT = { - "content-type": "application/json", +export const STATUS_RESPONSE_HEADERS = { + "content-type": "application/json; charset=utf-8", "cache-control": "no-store", -}; + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", +} as const; function send( res: http.ServerResponse, @@ -45,7 +47,7 @@ function send( body: unknown, ): void { const payload = JSON.stringify(body, bigintReplacer, 2); - res.writeHead(status, JSON_CONTENT); + res.writeHead(status, STATUS_RESPONSE_HEADERS); res.end(payload); }