diff --git a/src/bounded-response.js b/src/bounded-response.js index 8ec30c99..2a7f3d03 100644 --- a/src/bounded-response.js +++ b/src/bounded-response.js @@ -1,5 +1,10 @@ // Read an HTTP response body with a hard byte cap, so a hostile or broken // server can't make the updater buffer an unbounded amount of memory. +// Start a cancel without waiting for it or letting it replace the real error. +function cancelQuietly(target) { + try { Promise.resolve(target?.cancel?.()).catch(() => {}); } catch { /* ignore */ } +} + export async function readBoundedBytes(response, limit, label = "response") { if (!Number.isSafeInteger(limit) || limit < 1) throw new TypeError("limit: expected a positive integer"); const rawLength = response?.headers?.get?.("content-length"); @@ -8,7 +13,7 @@ export async function readBoundedBytes(response, limit, label = "response") { if (Number.isFinite(declared) && declared > limit) { // Release the connection instead of leaving the unread body open. // Not awaited: a stream whose cancel() never settles must not hold back the rejection. - Promise.resolve(body?.cancel?.()).catch(() => {}); + cancelQuietly(body); throw new Error(`${label} is too large`); } if (body && typeof body.getReader === "function") { @@ -24,7 +29,7 @@ export async function readBoundedBytes(response, limit, label = "response") { chunks.push(value); } } catch (error) { - await reader.cancel().catch(() => {}); + cancelQuietly(reader); throw error; } const bytes = new Uint8Array(total); diff --git a/test/bounded-response.test.js b/test/bounded-response.test.js index 6ac6bb3d..6d18b6d3 100644 --- a/test/bounded-response.test.js +++ b/test/bounded-response.test.js @@ -79,3 +79,18 @@ test("a hanging body cancel does not delay the size rejection", async () => { ]); assert.equal(outcome, "Artwork is too large"); }); + +test("a throwing body cancel does not replace the size error", async () => { + const body = { cancel() { throw new Error("cancel exploded"); } }; + const response = { headers: new Headers({ "content-length": "999999" }), body }; + await assert.rejects(readBoundedBytes(response, 1000, "Artwork"), /Artwork is too large/); +}); + +test("a hanging reader cancel does not delay a mid-read overflow rejection", async () => { + const body = new ReadableStream({ pull(controller) { controller.enqueue(new Uint8Array(600)); }, cancel() { return new Promise(() => {}); } }); + const outcome = await Promise.race([ + readBoundedBytes(new Response(body), 1000, "Artwork").then(() => "resolved", (error) => error.message), + new Promise((resolve) => setTimeout(() => resolve("hung"), 500)), + ]); + assert.equal(outcome, "Artwork is too large"); +});