diff --git a/host/src/qemu/http.ts b/host/src/qemu/http.ts index 550960f..9c41efa 100644 --- a/host/src/qemu/http.ts +++ b/host/src/qemu/http.ts @@ -1365,11 +1365,18 @@ export async function fetchHookRequestAndRespond( ? new Uint8Array(currentRequest.body) : undefined; + // Avoid duplicate Content-Length when Fetch derives framing for buffered bodies. + // Remove it from a copy so redirects and hooks keep the measured length. + const fetchHeaders = { ...currentRequest.headers }; + if (bodyInit && !bodyStream) { + delete fetchHeaders["content-length"]; + } + let response: FetchResponse; try { response = await fetcher(currentUrl.toString(), { method: currentRequest.method, - headers: currentRequest.headers, + headers: fetchHeaders, body: bodyInit as any, redirect: "manual", ...(bodyStream ? { duplex: "half" } : {}), diff --git a/host/test/qemu-net.test.ts b/host/test/qemu-net.test.ts index 1f1078c..05907f4 100644 --- a/host/test/qemu-net.test.ts +++ b/host/test/qemu-net.test.ts @@ -1619,6 +1619,60 @@ test("qemu-net: streaming onRequest clone-read preserves forwarded body", async } }); +test("qemu-net: buffered body drops content-length before fetch (undici duplicate guard)", async () => { + // undici 6 duplicates an explicit Content-Length for buffered bodies; built-in + // undici 7.28 rejects it, so Fetch must derive the length instead. + let sentHeaders: Record | undefined; + let sentBodyLength: number | undefined; + const session: any = { http: undefined }; + + const backend = makeBackend({ + maxHttpBodyBytes: 1024, + fetch: async (_url, init) => { + sentHeaders = init?.headers as Record; + sentBodyLength = (init?.body as Uint8Array | undefined)?.length; + return new Response("ok", { + status: 200, + headers: { "content-length": "2" }, + }); + }, + }); + + let finished = false; + await qemuHttp.handleHttpDataWithWriter( + backend, + "key", + session, + Buffer.from( + "POST / HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Content-Length: 5\r\n" + + "\r\n" + + "hello", + ), + { + scheme: "http", + write: () => {}, + finish: () => { + finished = true; + }, + }, + ); + + assert.equal(finished, true); + assert.ok(sentHeaders, "expected fetch to be called"); + const contentLengthKeys = Object.keys(sentHeaders).filter( + (key) => key.toLowerCase() === "content-length", + ); + assert.deepEqual( + contentLengthKeys, + [], + "buffered body must not forward content-length to fetch", + ); + assert.equal(sentBodyLength, 5); + assert.equal(sentHeaders.host, "example.com"); +}); + test("qemu-net: streaming onRequest body rewrite drains remaining upload bytes", async () => { let releaseFetch: (() => void) | null = null; const fetchGate = new Promise((resolve) => {