Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<string> {
Expand Down Expand Up @@ -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<string, string> = {};
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;
});
Expand Down
115 changes: 115 additions & 0 deletions tests/decode-fail-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
});
Loading