diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d4ad3bff4d..be879e0da0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1487,13 +1487,7 @@ async function forwardOpencodeV2Request(input: { target.searchParams.set("location[directory]", input.workspace.path); } - const headers = new Headers(input.request.headers); - headers.delete("authorization"); - headers.delete("x-openwork-host-token"); - headers.delete("x-openwork-client-id"); - headers.delete("host"); - headers.delete("origin"); - headers.delete("transfer-encoding"); + const headers = forwardableProxyRequestHeaders(input.request.headers); headers.set("authorization", `Basic ${Buffer.from(`opencode:${input.connection.password}`).toString("base64")}`); // Like the v1 proxy: an engine that is down or restarting is an expected @@ -1978,13 +1972,7 @@ async function forwardOpencodeRequest(input: { throw new ApiError(400, "opencode_unconfigured", "OpenCode base URL is missing for this workspace"); } - let headers = new Headers(input.request.headers); - headers.delete("authorization"); - headers.delete("x-openwork-host-token"); - headers.delete("x-openwork-client-id"); - headers.delete("host"); - headers.delete("origin"); - headers.delete("transfer-encoding"); + let headers = forwardableProxyRequestHeaders(input.request.headers); const directory = workspace ? resolveOpencodeDirectory(workspace) : null; let search = input.url.search; @@ -2409,6 +2397,35 @@ async function proxyEngineEventStreams(input: { }); } +/** + * Copy client request headers for forwarding to the engine, dropping + * hop-by-hop and transport-level headers. Node's fetch (undici) rejects some + * of these outright (e.g. `expect: 100-continue` throws NotSupportedError + * "expect header not supported", surfacing as `TypeError: fetch failed`), and + * none of them are meaningful across a proxy hop. + */ +const PROXY_STRIPPED_REQUEST_HEADERS = [ + "authorization", + "x-openwork-host-token", + "x-openwork-client-id", + "host", + "origin", + "transfer-encoding", + "expect", + "connection", + "keep-alive", + "proxy-connection", + "upgrade", + "te", + "trailer", +] as const; + +export function forwardableProxyRequestHeaders(source: HeadersInit): Headers { + const headers = new Headers(source); + for (const name of PROXY_STRIPPED_REQUEST_HEADERS) headers.delete(name); + return headers; +} + /** * Strip hop-by-hop and transport-level headers that Bun's native fetch keeps * in the upstream response even after it has already decoded the body for us. diff --git a/apps/server/test/proxy-request-headers.test.ts b/apps/server/test/proxy-request-headers.test.ts new file mode 100644 index 0000000000..bb216e4769 --- /dev/null +++ b/apps/server/test/proxy-request-headers.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { forwardableProxyRequestHeaders } from "../src/server.js"; + +describe("forwardableProxyRequestHeaders", () => { + test("drops expect and hop-by-hop headers that Node fetch (undici) rejects", () => { + const headers = forwardableProxyRequestHeaders({ + "Expect": "100-continue", + "Connection": "keep-alive", + "Keep-Alive": "timeout=5", + "Transfer-Encoding": "chunked", + "Upgrade": "h2c", + "TE": "gzip", + "Host": "127.0.0.1:1234", + "Origin": "http://localhost", + "Authorization": "Bearer client", + "X-OpenWork-Host-Token": "secret", + "X-OpenWork-Client-Id": "client", + "Content-Type": "application/json", + "X-Custom": "kept", + }); + for (const name of ["expect", "connection", "keep-alive", "transfer-encoding", "upgrade", "te", "host", "origin", "authorization", "x-openwork-host-token", "x-openwork-client-id"]) { + expect(headers.has(name)).toBe(false); + } + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("x-custom")).toBe("kept"); + }); + + test("sanitized headers are accepted by fetch", async () => { + const server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); + try { + const headers = forwardableProxyRequestHeaders({ "Expect": "100-continue", "Content-Type": "application/json" }); + const response = await fetch(`http://127.0.0.1:${server.port}/`, { method: "POST", headers, body: "{}" }); + expect(await response.text()).toBe("ok"); + } finally { + server.stop(true); + } + }); +});