Skip to content

Commit ea0b6d4

Browse files
syucreamclaude
andauthored
fix(proxy): drop Content-Encoding from forwarded upstream responses (#50)
* fix(proxy): drop Content-Encoding from forwarded upstream responses `fetch` transparently decodes the upstream body — the runtime adds its own Accept-Encoding to the outgoing request — but the Response it hands back still advertises the upstream's `Content-Encoding` and the compressed `Content-Length`. `forwardRequest` returned that object verbatim, so the proxy answered with a plain body labelled `content-encoding: br` and a length measuring bytes it never sent. Any client that honours the header then fails to decode. n8n-cli's own API client dies on the first list call: Error: network error: BrotliDecompressionError fetching ".../api/v1/workflows?limit=2" which makes the CLI unusable against a deployed proxy. curl only escaped it because it doesn't request compression by default, so the breakage hid behind manual testing. Rebuild the response without those two headers when the upstream declares a real content coding. Responses without an encoding (or `identity`) keep the original object, so the mutable headers `handleWorkflowMutation` writes lint counters onto are untouched. The new test fails with a ZlibError before this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: bump version to 2.5.0 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f3f879d commit ea0b6d4

3 files changed

Lines changed: 150 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "n8n-cli",
3-
"version": "2.4.0",
3+
"version": "2.5.0",
44
"module": "src/index.ts",
55
"type": "module",
66
"private": true,

src/proxy/upstream.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
* client is expected to supply them and the upstream needs them to
1313
* authenticate. Client middlewares (see `ClientMiddleware`) can rewrite or
1414
* replace these after the strip step but before fetch.
15+
*
16+
* On the way back, `Content-Encoding` (and the now-stale `Content-Length`) are
17+
* dropped from the upstream response — see `normalizeResponseEncoding`.
1518
*/
1619
import { runClientPipeline } from "@/middleware/client-pipeline.ts";
1720
import type { ClientMiddleware } from "@/middleware/types.ts";
@@ -47,6 +50,41 @@ function buildUpstreamHeaders(req: Request): Headers {
4750
return headers;
4851
}
4952

53+
/**
54+
* Rebuilds an upstream response so its headers describe the body we actually
55+
* hand downstream.
56+
*
57+
* `fetch` negotiates and transparently *decodes* the response body (the
58+
* outgoing request carries an `Accept-Encoding` the runtime adds on its own),
59+
* but the `Response` it returns still advertises the upstream's
60+
* `Content-Encoding` and the compressed `Content-Length`. Forwarding those
61+
* verbatim hands the client a plain body labelled `content-encoding: br`,
62+
* which any client that honours the header fails to decode — n8n-cli's own
63+
* API client dies with `BrotliDecompressionError`. curl only escapes this
64+
* because it doesn't ask for compression by default.
65+
*
66+
* So: when the upstream declares a real content coding, strip both headers and
67+
* let the runtime recompute the length. Responses without an encoding (or with
68+
* `identity`) pass through untouched, keeping the original object — and its
69+
* mutable headers, which `handleWorkflowMutation` relies on to attach lint
70+
* counters.
71+
*/
72+
function normalizeResponseEncoding(response: Response): Response {
73+
const encoding = response.headers.get("content-encoding");
74+
if (!encoding || encoding.trim().toLowerCase() === "identity") return response;
75+
76+
const headers = new Headers(response.headers);
77+
headers.delete("content-encoding");
78+
// Stale: it measures the compressed bytes, not the decoded body we forward.
79+
headers.delete("content-length");
80+
81+
return new Response(response.body, {
82+
status: response.status,
83+
statusText: response.statusText,
84+
headers,
85+
});
86+
}
87+
5088
export interface ForwardOptions {
5189
/** Total request timeout in milliseconds; 0 disables timeout. */
5290
timeoutMs?: number;
@@ -107,7 +145,7 @@ export async function forwardRequest(
107145
try {
108146
const response = await fetch(upstreamUrl, init);
109147
const elapsedMs = Math.round(performance.now() - start);
110-
return { response, elapsedMs };
148+
return { response: normalizeResponseEncoding(response), elapsedMs };
111149
} finally {
112150
if (timer) clearTimeout(timer);
113151
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { type ProxyHandle, startProxy } from "@/proxy/server.ts";
3+
4+
/**
5+
* Regression tests for forwarding compressed upstream responses.
6+
*
7+
* `fetch` decodes the upstream body on its own but leaves `Content-Encoding`
8+
* and the compressed `Content-Length` on the Response. Passing those through
9+
* hands the client a plain body labelled `content-encoding: gzip`, which any
10+
* client that honours the header fails to decode.
11+
*/
12+
13+
interface MockUpstream {
14+
server: ReturnType<typeof Bun.serve>;
15+
port: number;
16+
}
17+
18+
/** Upstream that answers with a gzip-encoded JSON body, as n8n's LB does. */
19+
function startCompressingUpstream(payload: unknown): MockUpstream {
20+
const raw = Buffer.from(JSON.stringify(payload));
21+
const gzipped = Bun.gzipSync(raw);
22+
const server = Bun.serve({
23+
port: 0,
24+
fetch: () =>
25+
new Response(gzipped, {
26+
status: 200,
27+
headers: {
28+
"content-type": "application/json; charset=utf-8",
29+
"content-encoding": "gzip",
30+
// The compressed length — exactly what makes a verbatim forward wrong.
31+
"content-length": String(gzipped.byteLength),
32+
"x-upstream-marker": "kept",
33+
},
34+
}),
35+
});
36+
return { server, port: server.port! };
37+
}
38+
39+
/** Upstream that answers uncompressed, to prove the pass-through path is untouched. */
40+
function startPlainUpstream(payload: unknown): MockUpstream {
41+
const body = JSON.stringify(payload);
42+
const server = Bun.serve({
43+
port: 0,
44+
fetch: () =>
45+
new Response(body, {
46+
status: 200,
47+
headers: {
48+
"content-type": "application/json; charset=utf-8",
49+
"content-length": String(Buffer.byteLength(body)),
50+
"x-upstream-marker": "kept",
51+
},
52+
}),
53+
});
54+
return { server, port: server.port! };
55+
}
56+
57+
let upstream: MockUpstream | undefined;
58+
let proxy: ProxyHandle | undefined;
59+
60+
afterEach(async () => {
61+
await proxy?.stop();
62+
await upstream?.server.stop(true);
63+
proxy = undefined;
64+
upstream = undefined;
65+
});
66+
67+
function startProxyAgainst(port: number): ProxyHandle {
68+
return startProxy({
69+
listen: "127.0.0.1:0",
70+
upstream: `http://127.0.0.1:${port}`,
71+
enforce: "off",
72+
disableRules: [],
73+
logFormat: "json",
74+
allowDuplicates: true,
75+
});
76+
}
77+
78+
describe("proxy: upstream response encoding", () => {
79+
test("a compressed upstream response reaches the client decodable", async () => {
80+
const payload = { data: [{ id: "abc123", name: "workflow" }], nextCursor: null };
81+
upstream = startCompressingUpstream(payload);
82+
proxy = startProxyAgainst(upstream.port);
83+
84+
const res = await fetch(`http://127.0.0.1:${proxy.port}/api/v1/workflows`, {
85+
headers: { "accept-encoding": "gzip" },
86+
});
87+
88+
expect(res.status).toBe(200);
89+
// The body must be readable — before the fix this threw a decompression error.
90+
expect(await res.json()).toEqual(payload);
91+
// And the misleading headers must be gone rather than describing a body we no longer send.
92+
expect(res.headers.get("content-encoding")).toBeNull();
93+
// Unrelated upstream headers survive the rebuild.
94+
expect(res.headers.get("x-upstream-marker")).toBe("kept");
95+
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
96+
});
97+
98+
test("an uncompressed upstream response is forwarded untouched", async () => {
99+
const payload = { data: [], nextCursor: null };
100+
upstream = startPlainUpstream(payload);
101+
proxy = startProxyAgainst(upstream.port);
102+
103+
const res = await fetch(`http://127.0.0.1:${proxy.port}/api/v1/workflows`);
104+
105+
expect(res.status).toBe(200);
106+
expect(await res.json()).toEqual(payload);
107+
expect(res.headers.get("content-encoding")).toBeNull();
108+
expect(res.headers.get("x-upstream-marker")).toBe("kept");
109+
});
110+
});

0 commit comments

Comments
 (0)