diff --git a/src/server.ts b/src/server.ts index f6d507c7..79635191 100644 --- a/src/server.ts +++ b/src/server.ts @@ -212,10 +212,6 @@ const UPSTREAM_HOP_HEADERS = new Set([ "connection", "keep-alive", "transfer-encoding", - // Node's fetch transparently decodes compressed responses. Do not - // forward the upstream encoding marker when the body is rewritten or - // streamed from fetch, otherwise clients try to decompress plain bytes. - "content-encoding", // RFC 7230 §6.1 hop-by-hop headers. proxy-authorization in particular // carries client→proxy credentials that must never reach the model // endpoint. (#80) @@ -227,6 +223,18 @@ const UPSTREAM_HOP_HEADERS = new Set([ "upgrade", ]); +// content-encoding is end-to-end (RFC 9110 §7.2), NOT hop-by-hop — but the two +// directions need opposite treatment. RESPONSES: Node's fetch transparently +// decodes compressed bodies, so the upstream's encoding marker must not reach +// the client (it would try to decompress already-plain bytes) — stripped at +// the response-forward sites below. REQUESTS: the marker describes exactly the +// bytes bili forwards — decoded/rebuilt bodies have it dropped at decode time +// (handle()), while verbatim passthrough bodies (#619 undecodable encodings, +// unknown paths) MUST keep it so upstream applies its own decode; forwarding +// encoded request bytes without the marker made upstream reject undeclared +// binary bodies (#677). +const RESPONSE_ONLY_STRIP_HEADERS = new Set(["content-encoding"]); + // RFC 7230 §6.1: the Connection header names additional hop-by-hop headers // that must be stripped per-message. Returns their lowercased names. function connectionNamedHeaders(conn: string | string[] | undefined): Set { @@ -3448,14 +3456,14 @@ async function forward( const respConnNamed = connectionNamedHeaders(upstream.headers.get("connection") ?? undefined); upstream.headers.forEach((v, k) => { const lower = k.toLowerCase(); - if (UPSTREAM_HOP_HEADERS.has(lower) || respConnNamed.has(lower)) return; + if (UPSTREAM_HOP_HEADERS.has(lower) || RESPONSE_ONLY_STRIP_HEADERS.has(lower) || respConnNamed.has(lower)) return; respHeaders[k] = v; }); if (opts.debug) { const respLog: Record = {}; upstream.headers.forEach((v, k) => { const lower = k.toLowerCase(); - if (UPSTREAM_HOP_HEADERS.has(lower) || respConnNamed.has(lower)) return; + if (UPSTREAM_HOP_HEADERS.has(lower) || RESPONSE_ONLY_STRIP_HEADERS.has(lower) || respConnNamed.has(lower)) return; const masked = maskHeaderForLog(k, v); respLog[k] = masked.length > 300 ? masked.slice(0, 300) + "..." : masked; }); diff --git a/tests/decode-fail-passthrough.test.ts b/tests/decode-fail-passthrough.test.ts index 27277cf4..71cb5305 100644 --- a/tests/decode-fail-passthrough.test.ts +++ b/tests/decode-fail-passthrough.test.ts @@ -75,6 +75,8 @@ test("undecodable content-encoding body is forwarded verbatim instead of 400", a await res.arrayBuffer(); assert.equal(captured.length, 1); assert.deepEqual(captured[0].body, badBody); + // #677: verbatim bytes must reach upstream with their encoding declared. + assert.equal(captured[0].headers["content-encoding"], "gzip"); } finally { await close(proxy); await close(upstream); @@ -142,3 +144,116 @@ test("oversized decompressed body is rejected 413 and not forwarded", async () = await close(upstream); } }); + +// #677: unknown paths never attempt a decode, so their encoded bodies hit the +// same verbatim passthrough - the encoding marker must survive the forward too. +test("unknown-path passthrough keeps content-encoding on the forwarded request", async () => { + _setStoreForTest(new SessionStore({ enabled: false })); + setRegistryForTest({}); + const captured: Captured[] = []; + const upstream = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + captured.push({ url: req.url ?? "", headers: req.headers, body: Buffer.concat(chunks) }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + }); + upstream.listen(0, "127.0.0.1"); + await listen(upstream); + const upstreamPort = (upstream.address() as { port: number }).port; + const opts: ProxyOptions = { + port: 0, + host: "127.0.0.1", + upstream: "http://127.0.0.1", + routes: { + [`http://127.0.0.1:${upstreamPort}`]: { models: { "gpt-5": { context: 400_000 } } }, + }, + modelContextLimit: 400_000, + kernelConfig: defaultConfig(400_000), + compress: { injectTool: true, injectNudge: true }, + compat: { roles: {} }, + passthroughSource: null, + promptCache: { routing: "auto" }, + sessionHeader: "x-acp-session", + log: false, + debug: false, + passthrough: false, + autoUpdate: false, + mitm: { enabled: false, domains: [] }, + }; + const proxy = await startServer(opts); + await listen(proxy); + const proxyPort = (proxy.address() as { port: number }).port; + const base = `http://127.0.0.1:${proxyPort}/bili/http://127.0.0.1:${upstreamPort}`; + const { gzipSync } = await import("node:zlib"); + const wireBody = gzipSync(Buffer.from(JSON.stringify({ input: ["hi"] }), "utf8")); + try { + const res = await fetch(`${base}/v1/embeddings`, { + method: "POST", + headers: { + "content-encoding": "gzip", + "content-type": "application/json", + }, + body: wireBody, + }); + assert.equal(res.status, 200); + await res.arrayBuffer(); + assert.equal(captured.length, 1); + assert.deepEqual(captured[0].body, wireBody); + assert.equal(captured[0].headers["content-encoding"], "gzip"); + } finally { + await close(proxy); + await close(upstream); + } +}); + +// #677: the strip stays direction-specific - upstream RESPONSE encodings are +// still removed (Node fetch already decoded them) while requests pass through. +test("response content-encoding is stripped when forwarding upstream responses", async () => { + _setStoreForTest(new SessionStore({ enabled: false })); + setRegistryForTest({}); + const { gzipSync } = await import("node:zlib"); + const upstream = http.createServer((req, res) => { + req.resume(); + res.writeHead(200, { "content-type": "application/json", "content-encoding": "gzip" }); + res.end(gzipSync(Buffer.from(JSON.stringify({ ok: true }), "utf8"))); + }); + upstream.listen(0, "127.0.0.1"); + await listen(upstream); + const upstreamPort = (upstream.address() as { port: number }).port; + const opts: ProxyOptions = { + port: 0, + host: "127.0.0.1", + upstream: "http://127.0.0.1", + routes: { + [`http://127.0.0.1:${upstreamPort}`]: { models: { "gpt-5": { context: 400_000 } } }, + }, + modelContextLimit: 400_000, + kernelConfig: defaultConfig(400_000), + compress: { injectTool: true, injectNudge: true }, + compat: { roles: {} }, + passthroughSource: null, + promptCache: { routing: "auto" }, + sessionHeader: "x-acp-session", + log: false, + debug: false, + passthrough: false, + autoUpdate: false, + mitm: { enabled: false, domains: [] }, + }; + const proxy = await startServer(opts); + await listen(proxy); + const proxyPort = (proxy.address() as { port: number }).port; + const base = `http://127.0.0.1:${proxyPort}/bili/http://127.0.0.1:${upstreamPort}`; + try { + const res = await fetch(`${base}/v1/embeddings`, { method: "POST", body: "{}" }); + assert.equal(res.status, 200); + assert.equal(res.headers.get("content-encoding"), null); + assert.deepEqual(JSON.parse(await res.text()), { ok: true }); + } finally { + await close(proxy); + await close(upstream); + } +});