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
9 changes: 7 additions & 2 deletions src/bounded-response.js
Original file line number Diff line number Diff line change
@@ -1,57 +1,62 @@
// 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");
const declared = typeof rawLength === "string" && rawLength.trim() !== "" ? Number(rawLength) : NaN;
const body = response?.body;
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") {
const reader = body.getReader();
const chunks = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > limit) throw new Error(`${label} is too large`);
chunks.push(value);
}
} catch (error) {
await reader.cancel().catch(() => {});
cancelQuietly(reader);
throw error;
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
return bytes;
}
if (typeof response?.arrayBuffer !== "function" && typeof response?.text !== "function") {
throw new TypeError(`${label} has no readable body`);
}
// Non-streaming adapters hand over the whole body before its size can be
// measured, so they are only read when the server declared a sane length
// within the cap. Residual: a dishonest declared length can still make
// such an adapter buffer too much; only the streamed path above enforces
// the cap as bytes arrive.
if (!Number.isSafeInteger(declared) || declared < 0) throw new Error(`${label} is too large`);
if (typeof response?.arrayBuffer === "function") {
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > limit) throw new Error(`${label} is too large`);
return bytes;
}
if (typeof response?.text === "function") {
const bytes = new TextEncoder().encode(await response.text());
if (bytes.byteLength > limit) throw new Error(`${label} is too large`);
return bytes;
}
}

Check notice on line 59 in src/bounded-response.js

View check run for this annotation

codefactor.io / CodeFactor

src/bounded-response.js#L8-L59

Complex Method

export function timeoutSignal(ms) {
return typeof AbortSignal?.timeout === "function" ? AbortSignal.timeout(ms) : undefined;
Expand Down
15 changes: 15 additions & 0 deletions test/bounded-response.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading