From f1cfee4864fa50802b4926c78c1fdbc8780f962e Mon Sep 17 00:00:00 2001 From: Hans Sperker Date: Tue, 20 Jan 2026 15:57:49 +0100 Subject: [PATCH 1/3] test: add test to catch missing auth header bug --- tests/00-missing-auth-header.test.js | 51 ++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/00-missing-auth-header.test.js diff --git a/tests/00-missing-auth-header.test.js b/tests/00-missing-auth-header.test.js new file mode 100644 index 0000000..09a6369 --- /dev/null +++ b/tests/00-missing-auth-header.test.js @@ -0,0 +1,51 @@ +import { strict as assert } from "assert"; + +const BASE_URL = process.env.RESPONSES_BASE_URL ?? "http://localhost:3000"; + +describe("missing Authorization header", function () { + // Matches the existing test suite behavior: tests expect a running dev server. + before(async function () { + try { + const response = await fetch(`${BASE_URL}/`); + if (response.status !== 200) { + throw new Error(`Server returned status ${response.status}`); + } + } catch (error) { + console.error("❌ Server is not running. Please start the server with 'pnpm dev' before running the tests."); + throw error; + } + }); + + it("streaming request returns 401 (does not start an SSE stream)", async function () { + const res = await fetch(`${BASE_URL}/v1/responses`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify({ model: "gpt-4.1-mini", input: "hi", stream: true }), + }); + + assert.equal(res.status, 401); + + const contentType = res.headers.get("content-type") ?? ""; + assert.ok(!contentType.includes("text/event-stream"), `Expected non-SSE response, got content-type: ${contentType}`); + }); + + it("non-streaming request returns 401 and server remains healthy", async function () { + const res = await fetch(`${BASE_URL}/v1/responses`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ model: "gpt-4.1-mini", input: "hi" }), + }); + + assert.equal(res.status, 401); + + // Regression: the handler must not crash the whole process after responding. + // (The bug was triggering "Cannot set headers after they are sent to the client".) + const healthRes = await fetch(`${BASE_URL}/health`); + assert.equal(healthRes.status, 200); + }); +}); From a1cf40c63706f379b066d045b08bc1902d064f75 Mon Sep 17 00:00:00 2001 From: Hans Sperker Date: Tue, 20 Jan 2026 16:45:10 +0100 Subject: [PATCH 2/3] fix: prevent crash on missing auth by failing before streaming starts --- src/routes/responses.ts | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/routes/responses.ts b/src/routes/responses.ts index d61206b..15599d3 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -56,8 +56,23 @@ export const postCreateResponse = async ( req: ValidatedRequest, res: ExpressResponse ): Promise => { + // This service doesn't validate the token, but it cannot proceed without one. + // Fail early before we start streaming (once we write SSE bytes, we can't return a 401). + const authorizationHeader = req.headers.authorization; + const apiKey = + typeof authorizationHeader === "string" && authorizationHeader.startsWith("Bearer ") + ? authorizationHeader.slice("Bearer ".length).trim() + : undefined; + if (!apiKey) { + res.status(401).json({ + success: false, + error: "Unauthorized", + }); + return; + } + // To avoid duplicated code, we run all requests as stream. - const events = runCreateResponseStream(req, res); + const events = runCreateResponseStream(req, res, apiKey); // Then we return in the correct format depending on the user 'stream' flag. if (req.body.stream) { @@ -88,7 +103,8 @@ export const postCreateResponse = async ( */ async function* runCreateResponseStream( req: ValidatedRequest, - res: ExpressResponse + res: ExpressResponse, + apiKey: string ): AsyncGenerator { let sequenceNumber = 0; // Prepare response object that will be iteratively populated @@ -134,7 +150,7 @@ async function* runCreateResponseStream( // Any events (LLM call, MCP call, list tools, etc.) try { - for await (const event of innerRunStream(req, res, responseObject)) { + for await (const event of innerRunStream(req, res, responseObject, apiKey)) { yield { ...event, sequence_number: sequenceNumber++ }; } } catch (error) { @@ -174,17 +190,9 @@ async function* runCreateResponseStream( async function* innerRunStream( req: ValidatedRequest, res: ExpressResponse, - responseObject: IncompleteResponse + responseObject: IncompleteResponse, + apiKey: string ): AsyncGenerator { - // Retrieve API key from headers - const apiKey = req.headers.authorization?.split(" ")[1]; - if (!apiKey) { - res.status(401).json({ - success: false, - error: "Unauthorized", - }); - return; - } // Forward headers (except authorization handled separately) const defaultHeaders = Object.fromEntries( From 09eb5ca0c177b39b3a7f9f64c37fc4e04453c159 Mon Sep 17 00:00:00 2001 From: Hans Sperker Date: Tue, 20 Jan 2026 17:05:38 +0100 Subject: [PATCH 3/3] refactor: centralize Authorization bearer token extraction --- src/routes/responses.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/routes/responses.ts b/src/routes/responses.ts index 15599d3..33e3eec 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -52,17 +52,28 @@ const NOT_FORWARDED_HEADERS = new Set([ "upgrade", ]); +function getBearerToken(authorizationHeader: unknown): string | undefined { + if (typeof authorizationHeader !== "string") { + return undefined; + } + + // We don't validate the token - just extract it in a safe, predictable way. + const match = authorizationHeader.match(/^Bearer\s+(.+)$/i); + if (!match) { + return undefined; + } + + const token = match[1].trim(); + return token.length > 0 ? token : undefined; +} + export const postCreateResponse = async ( req: ValidatedRequest, res: ExpressResponse ): Promise => { // This service doesn't validate the token, but it cannot proceed without one. // Fail early before we start streaming (once we write SSE bytes, we can't return a 401). - const authorizationHeader = req.headers.authorization; - const apiKey = - typeof authorizationHeader === "string" && authorizationHeader.startsWith("Bearer ") - ? authorizationHeader.slice("Bearer ".length).trim() - : undefined; + const apiKey = getBearerToken(req.headers.authorization); if (!apiKey) { res.status(401).json({ success: false,