From dee99f27d1d1a48b446afe32b0d9c53d62e3cdbe Mon Sep 17 00:00:00 2001 From: Viz Date: Fri, 17 Apr 2026 23:03:49 -0400 Subject: [PATCH] fix(gateway): allow microphone access for same-origin in Permissions-Policy header (#68368) * test(gateway): add full unit coverage for http-common.ts Adds tests exercising every export in src/gateway/http-common.ts so the module reaches 100% line, branch, function and statement coverage (33 tests). Captures current default security headers (including the existing Permissions-Policy microphone=() deny-list) and exhaustively covers sendJson/sendText/sendMethodNotAllowed/sendUnauthorized/sendRateLimited (with and without Retry-After), sendGatewayAuthFailure (both branches), sendInvalidRequest, readJsonBodyOrError (413/408/400/success), writeDone, setSseHeaders (with and without flushHeaders) and watchClientDisconnect (empty/single/dedup/distinct sockets, abort logic and listener cleanup). * fix(gateway): allow microphone access for same-origin in Permissions-Policy header The gateway's default security headers set Permissions-Policy to microphone=(), which denies microphone access for every origin including the page itself. As a result, the control-ui chat mic button (ui/src/ui/chat/speech.ts) cannot start SpeechRecognition: the browser refuses with 'Permissions policy violation: microphone is not allowed in this document' and the button silently resets. Relax microphone to the same-origin allowlist (self) so the dashboard page can use the Web Speech API while still blocking third-party frames. Camera and geolocation remain fully denied. Fixes #51085 * test(gateway): add seeded property/fuzz tests for http-common.ts Adds src/gateway/http-common.fuzz.test.ts with 13 property-style tests (200 iterations each) driven by an in-file deterministic mulberry32 PRNG. Covers every export with invariants rather than fixed examples: baseline security headers across all opts shapes, Strict-Transport-Security iff non-empty string, sendJson/sendText status + body round-trips across random codes and payloads, sendMethodNotAllowed with random Allow values, sendRateLimited Retry-After iff retryAfterMs>0 with ceil-seconds value (including fractional ms), sendGatewayAuthFailure delegation, sendInvalidRequest message echo, readJsonBodyOrError status/body mapping across random error texts, writeDone sentinel, setSseHeaders with/without flushHeaders, and watchClientDisconnect invariants across arbitrary socket/controller/callback combinations (empty, same, distinct, pre-aborted). Deterministic seeds keep failures reproducible without introducing a new dev dependency. --- src/gateway/http-common.fuzz.test.ts | 451 +++++++++++++++++++++++++++ src/gateway/http-common.test.ts | 322 ++++++++++++++++++- src/gateway/http-common.ts | 2 +- 3 files changed, 770 insertions(+), 5 deletions(-) create mode 100644 src/gateway/http-common.fuzz.test.ts diff --git a/src/gateway/http-common.fuzz.test.ts b/src/gateway/http-common.fuzz.test.ts new file mode 100644 index 00000000000..878da3070e4 --- /dev/null +++ b/src/gateway/http-common.fuzz.test.ts @@ -0,0 +1,451 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayAuthResult } from "./auth.js"; +import { + readJsonBodyOrError, + sendGatewayAuthFailure, + sendInvalidRequest, + sendJson, + sendMethodNotAllowed, + sendRateLimited, + sendText, + sendUnauthorized, + setDefaultSecurityHeaders, + setSseHeaders, + watchClientDisconnect, + writeDone, +} from "./http-common.js"; +import { makeMockHttpResponse } from "./test-http-response.js"; + +/** + * Seeded property-based / fuzz coverage for http-common. + * + * The repo does not pull in fast-check, so this file ships a small, + * deterministic PRNG (mulberry32) + generators. Every property runs + * N iterations; any failure prints the seed-derived inputs so failures + * are reproducible. + */ + +const readJsonBodyMock = vi.hoisted(() => vi.fn()); + +vi.mock("./hooks.js", () => ({ + readJsonBody: readJsonBodyMock, +})); + +beforeEach(() => { + readJsonBodyMock.mockReset(); +}); + +/** Deterministic 32-bit PRNG. */ +function makeRng(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function randInt(rng: () => number, loInclusive: number, hiInclusive: number): number { + return Math.floor(rng() * (hiInclusive - loInclusive + 1)) + loInclusive; +} + +function randString(rng: () => number, maxLen = 48): string { + const len = randInt(rng, 0, maxLen); + let out = ""; + for (let i = 0; i < len; i += 1) { + // Mix ASCII printables, whitespace, and a few higher codepoints. + const bucket = rng(); + if (bucket < 0.7) { + out += String.fromCharCode(randInt(rng, 0x20, 0x7e)); + } else if (bucket < 0.85) { + out += " \t\n\r"[randInt(rng, 0, 3)]; + } else { + out += String.fromCharCode(randInt(rng, 0xa0, 0x2fff)); + } + } + return out; +} + +function randBody(rng: () => number): unknown { + const kind = randInt(rng, 0, 5); + if (kind === 0) { + return null; + } + if (kind === 1) { + return randString(rng, 32); + } + if (kind === 2) { + return randInt(rng, -1_000_000, 1_000_000); + } + if (kind === 3) { + return rng() < 0.5; + } + if (kind === 4) { + const n = randInt(rng, 0, 4); + const arr: unknown[] = []; + for (let i = 0; i < n; i += 1) { + arr.push(randInt(rng, 0, 100)); + } + return arr; + } + return { a: randString(rng, 12), b: randInt(rng, 0, 1000), c: rng() < 0.5 }; +} + +const ITERATIONS = 200; + +describe("fuzz: setDefaultSecurityHeaders", () => { + it("always emits the three baseline headers regardless of opts", () => { + const rng = makeRng(0xa11ce); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader } = makeMockHttpResponse(); + const shape = randInt(rng, 0, 3); + if (shape === 0) { + setDefaultSecurityHeaders(res); + } else if (shape === 1) { + setDefaultSecurityHeaders(res, undefined); + } else if (shape === 2) { + setDefaultSecurityHeaders(res, {}); + } else { + setDefaultSecurityHeaders(res, { strictTransportSecurity: randString(rng) }); + } + expect(setHeader).toHaveBeenCalledWith("X-Content-Type-Options", "nosniff"); + expect(setHeader).toHaveBeenCalledWith("Referrer-Policy", "no-referrer"); + expect(setHeader).toHaveBeenCalledWith( + "Permissions-Policy", + "camera=(), microphone=(self), geolocation=()", + ); + } + }); + + it("sets Strict-Transport-Security iff opts.strictTransportSecurity is a non-empty string", () => { + const rng = makeRng(0xb0b); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader } = makeMockHttpResponse(); + const value = randString(rng); + setDefaultSecurityHeaders(res, { strictTransportSecurity: value }); + const stsCalls = setHeader.mock.calls.filter( + (call) => call[0] === "Strict-Transport-Security", + ); + if (value.length > 0) { + expect(stsCalls).toHaveLength(1); + expect(stsCalls[0]?.[1]).toBe(value); + } else { + expect(stsCalls).toHaveLength(0); + } + } + }); +}); + +describe("fuzz: sendJson", () => { + it("propagates status, sets JSON content type, and serializes the body", () => { + const rng = makeRng(0xdecaf); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader, end } = makeMockHttpResponse(); + const status = randInt(rng, 100, 599); + const body = randBody(rng); + sendJson(res, status, body); + expect(res.statusCode).toBe(status); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "application/json; charset=utf-8"); + expect(end).toHaveBeenCalledWith(JSON.stringify(body)); + } + }); +}); + +describe("fuzz: sendText", () => { + it("propagates status, sets plain-text content type, and forwards the body", () => { + const rng = makeRng(0xfeed); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader, end } = makeMockHttpResponse(); + const status = randInt(rng, 100, 599); + const body = randString(rng, 64); + sendText(res, status, body); + expect(res.statusCode).toBe(status); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8"); + expect(end).toHaveBeenCalledWith(body); + } + }); +}); + +describe("fuzz: sendMethodNotAllowed", () => { + it("always responds 405 with the supplied Allow header (or POST when omitted)", () => { + const rng = makeRng(0x405); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader, end } = makeMockHttpResponse(); + const useDefault = rng() < 0.3; + const allow = useDefault ? undefined : randString(rng, 24); + if (allow === undefined) { + sendMethodNotAllowed(res); + expect(setHeader).toHaveBeenCalledWith("Allow", "POST"); + } else { + sendMethodNotAllowed(res, allow); + expect(setHeader).toHaveBeenCalledWith("Allow", allow); + } + expect(res.statusCode).toBe(405); + expect(end).toHaveBeenCalledWith("Method Not Allowed"); + } + }); +}); + +describe("fuzz: sendUnauthorized", () => { + it("is deterministic: always 401 with the canonical error payload", () => { + const expected = JSON.stringify({ + error: { message: "Unauthorized", type: "unauthorized" }, + }); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, end } = makeMockHttpResponse(); + sendUnauthorized(res); + expect(res.statusCode).toBe(401); + expect(end).toHaveBeenCalledWith(expected); + } + }); +}); + +describe("fuzz: sendRateLimited", () => { + it("sets Retry-After iff retryAfterMs is truthy and > 0, with ceil-seconds value", () => { + const rng = makeRng(0x429); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader } = makeMockHttpResponse(); + const pick = randInt(rng, 0, 4); + let retryAfterMs: number | undefined; + if (pick === 0) { + retryAfterMs = undefined; + } else if (pick === 1) { + retryAfterMs = 0; + } else if (pick === 2) { + retryAfterMs = -randInt(rng, 1, 100_000); + } else if (pick === 3) { + retryAfterMs = randInt(rng, 1, 3_600_000); + } else { + // Fractional positive values exercise Math.ceil. + retryAfterMs = rng() * 5000 + 0.001; + } + sendRateLimited(res, retryAfterMs); + expect(res.statusCode).toBe(429); + const retryCalls = setHeader.mock.calls.filter((call) => call[0] === "Retry-After"); + if (typeof retryAfterMs === "number" && retryAfterMs > 0) { + expect(retryCalls).toHaveLength(1); + expect(retryCalls[0]?.[1]).toBe(String(Math.ceil(retryAfterMs / 1000))); + } else { + expect(retryCalls).toHaveLength(0); + } + } + }); +}); + +describe("fuzz: sendGatewayAuthFailure", () => { + it("delegates to rate-limited vs unauthorized based on authResult.rateLimited", () => { + const rng = makeRng(0xba5e); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader, end } = makeMockHttpResponse(); + const rateLimited = rng() < 0.5; + const retryAfterMs = rateLimited && rng() < 0.7 ? randInt(rng, 1, 120_000) : undefined; + const authResult = { ok: false, rateLimited, retryAfterMs } as GatewayAuthResult; + sendGatewayAuthFailure(res, authResult); + if (rateLimited) { + expect(res.statusCode).toBe(429); + const retryCalls = setHeader.mock.calls.filter((call) => call[0] === "Retry-After"); + if (typeof retryAfterMs === "number" && retryAfterMs > 0) { + expect(retryCalls).toHaveLength(1); + } else { + expect(retryCalls).toHaveLength(0); + } + } else { + expect(res.statusCode).toBe(401); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "Unauthorized", type: "unauthorized" } }), + ); + } + } + }); +}); + +describe("fuzz: sendInvalidRequest", () => { + it("always responds 400 with the supplied message echoed into the payload", () => { + const rng = makeRng(0xbad); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, end } = makeMockHttpResponse(); + const message = randString(rng, 64); + sendInvalidRequest(res, message); + expect(res.statusCode).toBe(400); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message, type: "invalid_request_error" } }), + ); + } + }); +}); + +describe("fuzz: readJsonBodyOrError", () => { + const makeRequest = () => ({}) as IncomingMessage; + + it("maps readJsonBody results to the documented status/body contract", async () => { + const rng = makeRng(0xc0de); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, end } = makeMockHttpResponse(); + const pick = randInt(rng, 0, 3); + let expectedStatus: number | undefined; + let expectedBody: string | undefined; + let expectedValue: unknown; + + if (pick === 0) { + const value = randBody(rng); + expectedValue = value; + readJsonBodyMock.mockResolvedValueOnce({ ok: true, value }); + } else if (pick === 1) { + expectedStatus = 413; + expectedBody = JSON.stringify({ + error: { message: "Payload too large", type: "invalid_request_error" }, + }); + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "payload too large" }); + } else if (pick === 2) { + expectedStatus = 408; + expectedBody = JSON.stringify({ + error: { message: "Request body timeout", type: "invalid_request_error" }, + }); + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "request body timeout" }); + } else { + // Arbitrary error text must neither collide with the 413/408 sentinels + // nor accidentally reuse them; pick a prefix that can never match. + const text = `err-${randString(rng, 24)}`; + expectedStatus = 400; + expectedBody = JSON.stringify({ + error: { message: text, type: "invalid_request_error" }, + }); + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: text }); + } + + const maxBytes = randInt(rng, 1, 1 << 20); + const result = await readJsonBodyOrError(makeRequest(), res, maxBytes); + if (pick === 0) { + expect(result).toEqual(expectedValue); + } else { + expect(result).toBeUndefined(); + expect(res.statusCode).toBe(expectedStatus); + expect(end).toHaveBeenCalledWith(expectedBody); + } + expect(readJsonBodyMock).toHaveBeenLastCalledWith(expect.anything(), maxBytes); + } + }); +}); + +describe("fuzz: writeDone", () => { + it("always writes the DONE sentinel exactly once per call", () => { + for (let i = 0; i < ITERATIONS; i += 1) { + const { res } = makeMockHttpResponse(); + const write = vi.spyOn(res, "write"); + writeDone(res); + expect(write).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledWith("data: [DONE]\n\n"); + } + }); +}); + +describe("fuzz: setSseHeaders", () => { + it("sets SSE headers and invokes flushHeaders when present", () => { + const rng = makeRng(0x55e); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader } = makeMockHttpResponse(); + const hasFlush = rng() < 0.5; + const flushHeaders = vi.fn(); + if (hasFlush) { + (res as unknown as { flushHeaders: () => void }).flushHeaders = flushHeaders; + } + setSseHeaders(res); + expect(res.statusCode).toBe(200); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8"); + expect(setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache"); + expect(setHeader).toHaveBeenCalledWith("Connection", "keep-alive"); + if (hasFlush) { + expect(flushHeaders).toHaveBeenCalledTimes(1); + } else { + expect(flushHeaders).not.toHaveBeenCalled(); + } + } + }); +}); + +describe("fuzz: watchClientDisconnect", () => { + function buildReqRes( + reqSocket: EventEmitter | null, + resSocket: EventEmitter | null, + ): { req: IncomingMessage; res: ServerResponse } { + return { + req: { socket: reqSocket } as unknown as IncomingMessage, + res: { socket: resSocket } as unknown as ServerResponse, + }; + } + + it("invariants hold for arbitrary socket/controller/callback combinations", () => { + const rng = makeRng(0xc105e); + for (let i = 0; i < ITERATIONS; i += 1) { + const shape = randInt(rng, 0, 3); + const same = rng() < 0.4; + let reqSocket: EventEmitter | null = null; + let resSocket: EventEmitter | null = null; + if (shape === 0) { + // both null + } else if (shape === 1) { + reqSocket = new EventEmitter(); + } else if (shape === 2) { + resSocket = new EventEmitter(); + } else if (same) { + reqSocket = new EventEmitter(); + resSocket = reqSocket; + } else { + reqSocket = new EventEmitter(); + resSocket = new EventEmitter(); + } + + const preAborted = rng() < 0.25; + const hasCallback = rng() < 0.5; + const controller = new AbortController(); + if (preAborted) { + controller.abort(); + } + const onDisconnect = hasCallback ? vi.fn() : undefined; + + const { req, res } = buildReqRes(reqSocket, resSocket); + const cleanup = watchClientDisconnect(req, res, controller, onDisconnect); + expect(typeof cleanup).toBe("function"); + + const uniqueSockets = new Set(); + if (reqSocket) { + uniqueSockets.add(reqSocket); + } + if (resSocket) { + uniqueSockets.add(resSocket); + } + + // Each unique socket should have exactly one "close" listener registered + // (or zero when there are no sockets at all). + for (const s of uniqueSockets) { + expect(s.listenerCount("close")).toBe(1); + } + + // Fire close on every unique socket; invariants: callback fires once per + // close, controller becomes aborted (regardless of whether it started so). + let expectedCallbackCalls = 0; + for (const s of uniqueSockets) { + s.emit("close"); + expectedCallbackCalls += 1; + } + if (uniqueSockets.size > 0) { + expect(controller.signal.aborted).toBe(true); + if (onDisconnect) { + expect(onDisconnect).toHaveBeenCalledTimes(expectedCallbackCalls); + } + } else { + expect(controller.signal.aborted).toBe(preAborted); + } + + // Cleanup removes all registered listeners. + cleanup(); + for (const s of uniqueSockets) { + expect(s.listenerCount("close")).toBe(0); + } + } + }); +}); diff --git a/src/gateway/http-common.test.ts b/src/gateway/http-common.test.ts index 3292baed8c4..8342626937b 100644 --- a/src/gateway/http-common.test.ts +++ b/src/gateway/http-common.test.ts @@ -1,7 +1,33 @@ -import { describe, expect, it } from "vitest"; -import { setDefaultSecurityHeaders } from "./http-common.js"; +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayAuthResult } from "./auth.js"; +import { + readJsonBodyOrError, + sendGatewayAuthFailure, + sendInvalidRequest, + sendJson, + sendMethodNotAllowed, + sendRateLimited, + sendText, + sendUnauthorized, + setDefaultSecurityHeaders, + setSseHeaders, + watchClientDisconnect, + writeDone, +} from "./http-common.js"; import { makeMockHttpResponse } from "./test-http-response.js"; +const readJsonBodyMock = vi.hoisted(() => vi.fn()); + +vi.mock("./hooks.js", () => ({ + readJsonBody: readJsonBodyMock, +})); + +beforeEach(() => { + readJsonBodyMock.mockReset(); +}); + describe("setDefaultSecurityHeaders", () => { it("sets X-Content-Type-Options", () => { const { res, setHeader } = makeMockHttpResponse(); @@ -15,12 +41,12 @@ describe("setDefaultSecurityHeaders", () => { expect(setHeader).toHaveBeenCalledWith("Referrer-Policy", "no-referrer"); }); - it("sets Permissions-Policy", () => { + it("sets Permissions-Policy that allows microphone for same-origin", () => { const { res, setHeader } = makeMockHttpResponse(); setDefaultSecurityHeaders(res); expect(setHeader).toHaveBeenCalledWith( "Permissions-Policy", - "camera=(), microphone=(), geolocation=()", + "camera=(), microphone=(self), geolocation=()", ); }); @@ -46,4 +72,292 @@ describe("setDefaultSecurityHeaders", () => { setDefaultSecurityHeaders(res, { strictTransportSecurity: "" }); expect(setHeader).not.toHaveBeenCalledWith("Strict-Transport-Security", expect.anything()); }); + + it("does not set Strict-Transport-Security when opts is omitted", () => { + const { res, setHeader } = makeMockHttpResponse(); + setDefaultSecurityHeaders(res, undefined); + expect(setHeader).not.toHaveBeenCalledWith("Strict-Transport-Security", expect.anything()); + }); +}); + +describe("sendJson", () => { + it("sets status, content-type and writes JSON body", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + sendJson(res, 201, { ok: true }); + expect(res.statusCode).toBe(201); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "application/json; charset=utf-8"); + expect(end).toHaveBeenCalledWith(JSON.stringify({ ok: true })); + }); +}); + +describe("sendText", () => { + it("sets status, content-type and writes plain-text body", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + sendText(res, 202, "hello"); + expect(res.statusCode).toBe(202); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8"); + expect(end).toHaveBeenCalledWith("hello"); + }); +}); + +describe("sendMethodNotAllowed", () => { + it("defaults the Allow header to POST and responds 405", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + sendMethodNotAllowed(res); + expect(setHeader).toHaveBeenCalledWith("Allow", "POST"); + expect(res.statusCode).toBe(405); + expect(end).toHaveBeenCalledWith("Method Not Allowed"); + }); + + it("honours a custom Allow header value", () => { + const { res, setHeader } = makeMockHttpResponse(); + sendMethodNotAllowed(res, "GET, POST"); + expect(setHeader).toHaveBeenCalledWith("Allow", "GET, POST"); + }); +}); + +describe("sendUnauthorized", () => { + it("responds with 401 and a structured unauthorized payload", () => { + const { res, end } = makeMockHttpResponse(); + sendUnauthorized(res); + expect(res.statusCode).toBe(401); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "Unauthorized", type: "unauthorized" } }), + ); + }); +}); + +describe("sendRateLimited", () => { + it("responds with 429 and no Retry-After when retryAfterMs is omitted", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + sendRateLimited(res); + expect(res.statusCode).toBe(429); + expect(setHeader).not.toHaveBeenCalledWith("Retry-After", expect.anything()); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ + error: { + message: "Too many failed authentication attempts. Please try again later.", + type: "rate_limited", + }, + }), + ); + }); + + it("responds with 429 and no Retry-After when retryAfterMs is zero", () => { + const { res, setHeader } = makeMockHttpResponse(); + sendRateLimited(res, 0); + expect(res.statusCode).toBe(429); + expect(setHeader).not.toHaveBeenCalledWith("Retry-After", expect.anything()); + }); + + it("responds with 429 and no Retry-After when retryAfterMs is negative", () => { + const { res, setHeader } = makeMockHttpResponse(); + sendRateLimited(res, -500); + expect(res.statusCode).toBe(429); + expect(setHeader).not.toHaveBeenCalledWith("Retry-After", expect.anything()); + }); + + it("sets Retry-After (seconds, ceiled) when retryAfterMs is positive", () => { + const { res, setHeader } = makeMockHttpResponse(); + sendRateLimited(res, 1500); + expect(res.statusCode).toBe(429); + expect(setHeader).toHaveBeenCalledWith("Retry-After", "2"); + }); +}); + +describe("sendGatewayAuthFailure", () => { + it("delegates to sendRateLimited when the auth result is rate limited", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + const authResult = { ok: false, rateLimited: true, retryAfterMs: 3000 } as GatewayAuthResult; + sendGatewayAuthFailure(res, authResult); + expect(res.statusCode).toBe(429); + expect(setHeader).toHaveBeenCalledWith("Retry-After", "3"); + expect(end).toHaveBeenCalledTimes(1); + }); + + it("delegates to sendUnauthorized when the auth result is not rate limited", () => { + const { res, end } = makeMockHttpResponse(); + const authResult = { ok: false, rateLimited: false } as GatewayAuthResult; + sendGatewayAuthFailure(res, authResult); + expect(res.statusCode).toBe(401); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "Unauthorized", type: "unauthorized" } }), + ); + }); +}); + +describe("sendInvalidRequest", () => { + it("responds with 400 and includes the supplied message", () => { + const { res, end } = makeMockHttpResponse(); + sendInvalidRequest(res, "bad input"); + expect(res.statusCode).toBe(400); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "bad input", type: "invalid_request_error" } }), + ); + }); +}); + +describe("readJsonBodyOrError", () => { + const makeRequest = () => ({}) as IncomingMessage; + + it("returns the parsed body on success", async () => { + readJsonBodyMock.mockResolvedValueOnce({ ok: true, value: { hello: "world" } }); + const { res } = makeMockHttpResponse(); + const result = await readJsonBodyOrError(makeRequest(), res, 1024); + expect(result).toEqual({ hello: "world" }); + expect(readJsonBodyMock).toHaveBeenCalledWith(expect.anything(), 1024); + }); + + it("responds with 413 when the body is too large", async () => { + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "payload too large" }); + const { res, end } = makeMockHttpResponse(); + const result = await readJsonBodyOrError(makeRequest(), res, 1024); + expect(result).toBeUndefined(); + expect(res.statusCode).toBe(413); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ + error: { message: "Payload too large", type: "invalid_request_error" }, + }), + ); + }); + + it("responds with 408 when the request body times out", async () => { + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "request body timeout" }); + const { res, end } = makeMockHttpResponse(); + const result = await readJsonBodyOrError(makeRequest(), res, 1024); + expect(result).toBeUndefined(); + expect(res.statusCode).toBe(408); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ + error: { message: "Request body timeout", type: "invalid_request_error" }, + }), + ); + }); + + it("responds with 400 for other parse failures", async () => { + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "bad json" }); + const { res, end } = makeMockHttpResponse(); + const result = await readJsonBodyOrError(makeRequest(), res, 1024); + expect(result).toBeUndefined(); + expect(res.statusCode).toBe(400); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "bad json", type: "invalid_request_error" } }), + ); + }); +}); + +describe("writeDone", () => { + it("writes the SSE termination sentinel to the response stream", () => { + const { res } = makeMockHttpResponse(); + const write = vi.spyOn(res, "write"); + writeDone(res); + expect(write).toHaveBeenCalledWith("data: [DONE]\n\n"); + }); +}); + +describe("setSseHeaders", () => { + it("sets the SSE headers and calls flushHeaders when present", () => { + const { res, setHeader } = makeMockHttpResponse(); + const flushHeaders = vi.fn(); + (res as unknown as { flushHeaders: () => void }).flushHeaders = flushHeaders; + setSseHeaders(res); + expect(res.statusCode).toBe(200); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8"); + expect(setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache"); + expect(setHeader).toHaveBeenCalledWith("Connection", "keep-alive"); + expect(flushHeaders).toHaveBeenCalledTimes(1); + }); + + it("skips flushHeaders gracefully when the response does not expose one", () => { + const { res, setHeader } = makeMockHttpResponse(); + // Ensure flushHeaders is not defined on the mock response. + expect((res as unknown as { flushHeaders?: () => void }).flushHeaders).toBeUndefined(); + expect(() => setSseHeaders(res)).not.toThrow(); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8"); + }); +}); + +describe("watchClientDisconnect", () => { + function buildReqRes( + reqSocket: EventEmitter | null, + resSocket: EventEmitter | null, + ): { req: IncomingMessage; res: ServerResponse } { + return { + req: { socket: reqSocket } as unknown as IncomingMessage, + res: { socket: resSocket } as unknown as ServerResponse, + }; + } + + it("returns a no-op cleanup when no sockets are available", () => { + const { req, res } = buildReqRes(null, null); + const controller = new AbortController(); + const cleanup = watchClientDisconnect(req, res, controller); + expect(typeof cleanup).toBe("function"); + expect(() => cleanup()).not.toThrow(); + expect(controller.signal.aborted).toBe(false); + }); + + it("aborts the controller and calls onDisconnect when a socket closes", () => { + const socket = new EventEmitter(); + const { req, res } = buildReqRes(socket, socket); + const controller = new AbortController(); + const onDisconnect = vi.fn(); + watchClientDisconnect(req, res, controller, onDisconnect); + socket.emit("close"); + expect(onDisconnect).toHaveBeenCalledTimes(1); + expect(controller.signal.aborted).toBe(true); + }); + + it("does not double-abort when the controller is already aborted", () => { + const socket = new EventEmitter(); + const { req, res } = buildReqRes(socket, null); + const controller = new AbortController(); + controller.abort(); + const abortSpy = vi.spyOn(controller, "abort"); + const onDisconnect = vi.fn(); + watchClientDisconnect(req, res, controller, onDisconnect); + socket.emit("close"); + expect(onDisconnect).toHaveBeenCalledTimes(1); + expect(abortSpy).not.toHaveBeenCalled(); + }); + + it("works without an onDisconnect callback", () => { + const socket = new EventEmitter(); + const { req, res } = buildReqRes(null, socket); + const controller = new AbortController(); + watchClientDisconnect(req, res, controller); + socket.emit("close"); + expect(controller.signal.aborted).toBe(true); + }); + + it("deduplicates identical request and response sockets", () => { + const socket = new EventEmitter(); + const onSpy = vi.spyOn(socket, "on"); + const { req, res } = buildReqRes(socket, socket); + const controller = new AbortController(); + watchClientDisconnect(req, res, controller); + expect(onSpy).toHaveBeenCalledTimes(1); + }); + + it("registers handlers on distinct request and response sockets", () => { + const reqSocket = new EventEmitter(); + const resSocket = new EventEmitter(); + const reqOn = vi.spyOn(reqSocket, "on"); + const resOn = vi.spyOn(resSocket, "on"); + const { req, res } = buildReqRes(reqSocket, resSocket); + const controller = new AbortController(); + watchClientDisconnect(req, res, controller); + expect(reqOn).toHaveBeenCalledWith("close", expect.any(Function)); + expect(resOn).toHaveBeenCalledWith("close", expect.any(Function)); + }); + + it("cleanup detaches the close listener from each socket", () => { + const socket = new EventEmitter(); + const { req, res } = buildReqRes(socket, null); + const controller = new AbortController(); + const cleanup = watchClientDisconnect(req, res, controller); + expect(socket.listenerCount("close")).toBe(1); + cleanup(); + expect(socket.listenerCount("close")).toBe(0); + }); }); diff --git a/src/gateway/http-common.ts b/src/gateway/http-common.ts index d1927de021f..55da0613ee7 100644 --- a/src/gateway/http-common.ts +++ b/src/gateway/http-common.ts @@ -14,7 +14,7 @@ export function setDefaultSecurityHeaders( ) { res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("Referrer-Policy", "no-referrer"); - res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + res.setHeader("Permissions-Policy", "camera=(), microphone=(self), geolocation=()"); const strictTransportSecurity = opts?.strictTransportSecurity; if (typeof strictTransportSecurity === "string" && strictTransportSecurity.length > 0) { res.setHeader("Strict-Transport-Security", strictTransportSecurity);