fix(remediation-audit/s5): web JSON security headers + ticket TTL fixtures (Refs #282, Refs #283)

This commit is contained in:
John Smith
2026-10-10 21:05:11 -04:00
parent 3d59a7bce1
commit 9289804ad0
4 changed files with 144 additions and 17 deletions
+23 -11
View File
@@ -285,7 +285,11 @@ interface HttpResponder {
const jsonBody = (res: HttpResponder, code: number, value: unknown): void => {
const text = JSON.stringify(value)
res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(text) })
res.writeHead(code, {
...WEB_SECURITY_HEADERS,
"content-type": "application/json",
"content-length": Buffer.byteLength(text),
})
res.end(text)
}
@@ -430,7 +434,7 @@ export class TelemetryServer {
if ((field(headers, "upgrade") ?? "").toLowerCase() === "websocket" && target === "/fleet/ws")
return this.handleUpgrade(socket, headers, res)
if (!this.hostOk(field(headers, "host"))) {
res.writeHead(403, { "content-length": 0, connection: "close" })
res.writeHead(403, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
res.end()
return
}
@@ -440,7 +444,7 @@ export class TelemetryServer {
// (bearer or session), submit is browser-only (cookie + Origin + CSRF).
if (method === "POST" && target === "/fleet/approve/preview") return this.approvePreview(headers, rest, res)
if (method === "POST" && target === "/fleet/approve") return this.approveSubmit(headers, rest, res)
res.writeHead(404, { "content-length": 0, connection: "close" })
res.writeHead(404, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
res.end()
return
}
@@ -448,7 +452,7 @@ export class TelemetryServer {
// A presented session cookie that cannot authorize is a forbidden
// CSRF/origin failure (403); wholly missing credentials are 401.
const code = parseSessionCookie(field(headers, "cookie")) !== undefined ? 403 : 401
res.writeHead(code, { "content-length": 0, connection: "close" })
res.writeHead(code, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
res.end()
return
}
@@ -509,7 +513,7 @@ export class TelemetryServer {
res.end()
}
if (this.options.webRoot === undefined) {
res.writeHead(404, { "content-length": 0, connection: "close" })
res.writeHead(404, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
res.end()
return
}
@@ -580,7 +584,7 @@ export class TelemetryServer {
// Same convention as read-only RPC: a presented session cookie that
// cannot authorize is a forbidden CSRF/origin failure (403).
const code = parseSessionCookie(field(headers, "cookie")) !== undefined ? 403 : 401
res.writeHead(code, { "content-length": 0, connection: "close" })
res.writeHead(code, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
res.end()
return
}
@@ -637,12 +641,12 @@ export class TelemetryServer {
private async approveSubmit(headers: Map<string, string>, rest: Buffer, res: HttpResponder): Promise<void> {
const session = parseSessionCookie(field(headers, "cookie")) ?? ""
if (!this.sessions.valid(session)) {
res.writeHead(401, { "content-length": 0, connection: "close" })
res.writeHead(401, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
res.end()
return
}
if (!loopbackOriginOk(field(headers, "origin")) || !this.csrf.check(session, field(headers, "x-csrf"))) {
res.writeHead(403, { "content-length": 0, connection: "close" })
res.writeHead(403, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
res.end()
return
}
@@ -730,13 +734,21 @@ export class TelemetryServer {
private ok(res: HttpResponder, value: unknown): void {
const text = JSON.stringify(value)
res.writeHead(200, { "content-type": "application/json", "content-length": Buffer.byteLength(text) })
res.writeHead(200, {
...WEB_SECURITY_HEADERS,
"content-type": "application/json",
"content-length": Buffer.byteLength(text),
})
res.end(text)
}
private fail(res: HttpResponder, code: number, message: string, extra: Record<string, unknown> = {}): void {
const text = JSON.stringify({ error: message, ...extra })
res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(text) })
res.writeHead(code, {
...WEB_SECURITY_HEADERS,
"content-type": "application/json",
"content-length": Buffer.byteLength(text),
})
res.end(text)
}
@@ -818,7 +830,7 @@ export class TelemetryServer {
(!this.bearerOk(field(headers, "authorization")) && !cookieOk)
) {
const code = !this.hostOk(field(headers, "host")) || !this.originOk(field(headers, "origin")) ? 403 : 401
res.writeHead(code, { "content-length": 0, connection: "close" })
res.writeHead(code, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
res.end()
return
}
+45 -1
View File
@@ -22,7 +22,7 @@ const frontier = {
nodes: [{ id: "lang", question: "Language?", answer: "TypeScript", status: "settled" }],
}
async function rig() {
async function rig(extra: { approveTicketTtlMs?: number } = {}) {
const state = await mkdtemp(path.join(tmpdir(), "es-approve-"))
const repo = await mkdtemp(path.join(tmpdir(), "es-approve-repo-"))
await mkdir(path.join(repo, ".factory"), { recursive: true })
@@ -56,6 +56,7 @@ async function rig() {
stateRoot: state,
port: 0,
token: TOKEN,
...extra,
getSnapshot: (): FleetSnapshot => ({
daemon: { running: true, pid: 1, maxUsd: 10, concurrency: 1 },
tasks: [],
@@ -301,6 +302,49 @@ describe("approve submit", () => {
}
})
test("checklist 4b: an expired preview ticket fails the submit (#283.2)", async () => {
const { state, repo, server } = await rig({ approveTicketTtlMs: 0 })
try {
const cookie = await session(server.port, state)
const origin = `http://127.0.0.1:${server.port}`
const preview = json(
(
await post(
server.port,
"/fleet/approve/preview",
{ runId: "t1", stage: "frontier" },
{ Cookie: cookie, Origin: origin },
)
).body,
)
// A zero TTL expires the ticket before the submit even runs: no sleep needed.
const res = await approveAs(server.port, cookie, preview.csrf as string, {
ticket: preview.ticket,
passphrase: PASS,
})
expect(res.status).toBe(403)
expect(res.body).toContain("expired")
const { readApproval } = await import("@heretek-ai/es-core")
expect(await readApproval(repo, "frontier")).toBeUndefined()
} finally {
await server.stop()
await rm(state, { recursive: true, force: true })
await rm(repo, { recursive: true, force: true })
}
})
test("approve tickets expire by TTL at unit level (#283.2)", async () => {
const { ApproveTickets } = await import("../src/approve.ts")
const expired = new ApproveTickets(0)
expect(
expired.redeem(expired.issue({ repoDir: "r", stage: "frontier", subjectHash: "h" })),
).toBeUndefined()
const live = new ApproveTickets()
const ticket = live.issue({ repoDir: "r", stage: "frontier", subjectHash: "h" })
expect(live.redeem(ticket)).toEqual({ repoDir: "r", stage: "frontier", subjectHash: "h" })
expect(live.redeem(ticket)).toBeUndefined()
})
test("checklist 5: an artifact changed after the preview refuses the sign", async () => {
const { state, repo, server } = await rig()
try {
+4 -1
View File
@@ -1,6 +1,9 @@
// S5.4 (CodeQL #84) remote property injection (repair-151): request headers
// parse into a Map (never a plain `{}` keyed by remote names), non-token
// names are rejected, and `field()` is actually case-insensitive.
// names are rejected, and `field()` is actually case-insensitive. This file
// probes request-header parser hygiene only — web security headers (CSP) are
// proven in `web.test.ts` ("every web response carries the strict security
// headers", #283.1).
import { describe, expect, test } from "bun:test"
import { field, parseHeaders } from "../src/telemetry.ts"
+72 -4
View File
@@ -36,9 +36,13 @@ async function rig() {
return { state, webRoot, server, token }
}
const raw = (port: number, head: string): Promise<{ status: number; headers: Record<string, string>; body: string }> =>
const raw = (
port: number,
head: string,
body = "",
): Promise<{ status: number; headers: Record<string, string>; body: string }> =>
new Promise((resolve, reject) => {
const socket = createConnection(port, "127.0.0.1", () => socket.write(head))
const socket = createConnection(port, "127.0.0.1", () => socket.write(head + body))
let buffer = Buffer.alloc(0)
socket.on("data", (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk])
@@ -67,6 +71,24 @@ const get = (port: number, target: string, headers: Record<string, string> = {})
.join("")}\r\n`,
)
const postRpc = (port: number, payload: unknown, extra: Record<string, string> = {}) => {
const body = JSON.stringify(payload)
return raw(
port,
`POST /fleet/rpc HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n${Object.entries(
extra,
)
.map(([k, v]) => `${k}: ${v}\r\n`)
.join("")}\r\n`,
body,
)
}
const expectSecurityHeaders = (headers: Record<string, string>, where: string): void => {
for (const [name, value] of Object.entries(WEB_SECURITY_HEADERS))
expect(headers[name], `${where}: ${name}`).toBe(value)
}
describe("web tickets", () => {
test("a ticket redeems once, then is dead", async () => {
const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-"))
@@ -91,7 +113,7 @@ describe("web tickets", () => {
}
})
test("malformed and expired tickets are refused", async () => {
test("malformed and missing tickets are refused", async () => {
const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-"))
try {
expect(await redeemWebTicket(state, "not-hex")).toBe(false)
@@ -102,6 +124,35 @@ describe("web tickets", () => {
}
})
test("a minted-then-expired ticket is refused and consumed (#283.2)", async () => {
const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-"))
try {
const ticket = await mintWebTicket(state)
const { fleetDir } = await import("../src/state.ts")
// Age the ticket file past its TTL instead of sleeping for it.
await writeFile(path.join(fleetDir(state), "web-tickets", ticket), `${Date.now() - 1_000}\n`)
expect(await redeemWebTicket(state, ticket)).toBe(false)
expect(await redeemWebTicket(state, ticket)).toBe(false)
} finally {
await rm(state, { recursive: true, force: true })
}
})
test("an expired ticket redeems to 403 without a cookie at HTTP level (#283.2)", async () => {
const { state, server } = await rig()
try {
const ticket = await mintWebTicket(state)
const { fleetDir } = await import("../src/state.ts")
await writeFile(path.join(fleetDir(state), "web-tickets", ticket), `${Date.now() - 1_000}\n`)
const res = await get(server.port, `/?t=${ticket}`)
expect(res.status).toBe(403)
expect(res.headers["set-cookie"]).toBeUndefined()
} finally {
await server.stop()
await rm(state, { recursive: true, force: true })
}
})
test("cookie parsing extracts the session id", () => {
const id = "ab".repeat(32)
expect(parseSessionCookie(`a=1; ${WEB_SESSION_COOKIE}=${id}; b=2`)).toBe(id)
@@ -164,7 +215,7 @@ describe("web serving", () => {
})
test("every web response carries the strict security headers", async () => {
const { state, server } = await rig()
const { state, server, token } = await rig()
try {
const ticket = await mintWebTicket(state)
const login = await get(server.port, `/?t=${ticket}`)
@@ -174,6 +225,23 @@ describe("web serving", () => {
expect(login.headers["x-content-type-options"]).toBe("nosniff")
expect(login.headers["referrer-policy"]).toBe("no-referrer")
expect(WEB_SECURITY_HEADERS["content-security-policy"]).toContain("connect-src 'self'")
// JSON responders carry them too (#282): success, error envelopes, and
// the bare 401/403/404 paths.
const authed = { Authorization: `Bearer ${token}` }
const ok = await postRpc(server.port, { method: "fleet.status", params: {} }, authed)
expect(ok.status).toBe(200)
expect(ok.headers["content-type"]).toContain("application/json")
expectSecurityHeaders(ok.headers, "fleet.status 200")
const unknown = await postRpc(server.port, { method: "nope", params: {} }, authed)
expect(unknown.status).toBe(200)
expect(unknown.body).toContain("-32601")
expectSecurityHeaders(unknown.headers, "unknown method")
const anon = await postRpc(server.port, { method: "fleet.status", params: {} })
expect(anon.status).toBe(401)
expectSecurityHeaders(anon.headers, "anonymous rpc 401")
const missing = await raw(server.port, `GET /fleet/rpc HTTP/1.1\r\nHost: 127.0.0.1:${server.port}\r\nConnection: close\r\n\r\n`)
expect(missing.status).toBe(404)
expectSecurityHeaders(missing.headers, "wrong path 404")
} finally {
await server.stop()
await rm(state, { recursive: true, force: true })