Skip to content
Open
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
9 changes: 9 additions & 0 deletions services/keeper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
39 changes: 38 additions & 1 deletion services/keeper/src/status-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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}`);
}
});
});

10 changes: 6 additions & 4 deletions services/keeper/src/status-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,20 @@ 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,
status: number,
body: unknown,
): void {
const payload = JSON.stringify(body, bigintReplacer, 2);
res.writeHead(status, JSON_CONTENT);
res.writeHead(status, STATUS_RESPONSE_HEADERS);
res.end(payload);
}

Expand Down