fix(media): bound image-optimize fetch headroom to the image cap (#124838)

When a caller passed an explicit maxBytes with optimizeImages on (the
default), the source-read bound was inflated to max(maxBytes, 100MB
document cap). A channel with a 5MB attachment cap therefore let a
hostile or oversized URL buffer up to 100MB before the final size check
— 20x resource amplification, and the inflation applied before content
classification, so it was not limited to images.

The inflation exists so oversized-but-compressible originals can be
fetched and compressed under the delivery cap. Size that headroom off
the thing that justifies it: 4x the 6MB image cap (24MB), enough for
large phone photos, instead of the document cap.

Accepted tradeoff (named at the constant): originals above 24MB that
would have compressed under the cap now fail; the fetch error names the
bound. Callers without an explicit cap keep per-kind defaults.

Live proof on a real localhost HTTP server (no mocked fetch): an 8.1MB
PNG under a 5MB cap compresses to 3.75MB and delivers; a 30MB-declared
image is rejected at the Content-Length precheck in 7ms with 'content
length 31457280 exceeds maxBytes 25165824'.

Regression: headroom-bound test fails pre-fix (30MB passes the old
100MB bound); companion test proves compression headroom still works.
This commit is contained in:
Peter Steinberger
2026-08-16 14:23:25 -07:00
committed by GitHub
parent 63a3a958f4
commit 4aafed1f4a
2 changed files with 61 additions and 2 deletions
+48
View File
@@ -1506,6 +1506,54 @@ describe("loadWebMedia", () => {
}
});
it("bounds explicit-cap image fetches at the optimize headroom, not the document cap", async () => {
// 30MB declared original: over the 24MB image-optimize headroom but well
// under the old 100MB document bound. The Content-Length precheck must
// reject before any body bytes are read.
const declaredBytes = 30 * 1024 * 1024;
const fetchImpl = vi.fn(
async () =>
new Response(new ReadableStream<Uint8Array>(), {
status: 200,
headers: {
"content-type": "image/png",
"content-length": String(declaredBytes),
},
}),
);
await expect(
loadWebMedia("https://example.test/huge.png", {
maxBytes: 5 * 1024 * 1024,
fetchImpl,
ssrfPolicy: { allowedHostnames: ["example.test"] },
}),
).rejects.toThrow(/exceeds maxBytes/);
});
it("keeps compression headroom above an explicit cap for oversized originals", async () => {
// A 10MB-declared image is over the caller's 5MB cap but inside the
// optimize headroom: the fetch must proceed so compression can shrink it
// under the delivery cap.
const original = createSolidPngBuffer(64, 64, { r: 12, g: 34, b: 56 });
const fetchImpl = vi.fn(
async () =>
new Response(Buffer.from(original), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
const result = await loadWebMedia("https://example.test/photo.png", {
maxBytes: 5 * 1024 * 1024,
fetchImpl,
ssrfPolicy: { allowedHostnames: ["example.test"] },
});
expect(result.kind).toBe("image");
expect(result.buffer.length).toBeLessThanOrEqual(5 * 1024 * 1024);
});
it("applies the shared remote read idle timeout for raw web media loads", async () => {
const readIdleTimeoutMs = 20;
const fetchImpl = makeStallingFetch(new Uint8Array([0x25, 0x50, 0x44, 0x46]));
+13 -2
View File
@@ -160,6 +160,11 @@ function resolveWebMediaOptions(params: {
};
}
// Pre-compression fetch headroom for callers with an explicit delivery cap:
// enough to pull a large phone photo (~20MB+) and compress it under the cap,
// without letting a tight channel cap buffer up to the 100MB document bound.
const IMAGE_OPTIMIZE_HEADROOM_FACTOR = 4;
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
const HEIC_EXT_RE = /\.(heic|heif)$/i;
const WINDOWS_DRIVE_RE = /^[A-Za-z]:[\\/]/;
@@ -1131,13 +1136,19 @@ async function loadWebMediaInternal(
};
// Bound source reads before buffering. Optimized images may exceed their
// delivery cap because they are compressed before the final size check.
// delivery cap because they are compressed before the final size check, so
// an explicit caller cap gets image-compression headroom — sized off the
// image cap, not the 100MB document cap, or a tight channel cap would still
// permit a 100MB buffer from a hostile URL. Accepted tradeoff: originals
// above the headroom fail even when they would have compressed under the
// cap; the error names the fetch bound so the user can shrink the source.
const defaultSourceReadCap = maxBytesForKind("document");
const imageOptimizeHeadroom = IMAGE_OPTIMIZE_HEADROOM_FACTOR * maxBytesForKind("image");
const sourceReadCap =
maxBytes === undefined
? defaultSourceReadCap
: optimizeImages
? Math.max(maxBytes, defaultSourceReadCap)
? Math.max(maxBytes, imageOptimizeHeadroom)
: maxBytes;
if (hasHttpUrlPrefix(mediaUrl)) {