From 4aafed1f4a807edf6d3e38b202e27de751c71452 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 14:23:25 -0700 Subject: [PATCH] fix(media): bound image-optimize fetch headroom to the image cap (#124838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/media/web-media.test.ts | 48 +++++++++++++++++++++++++++++++++++++ src/media/web-media.ts | 15 ++++++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/media/web-media.test.ts b/src/media/web-media.test.ts index b0108d97fa4c..03dad5c37990 100644 --- a/src/media/web-media.test.ts +++ b/src/media/web-media.test.ts @@ -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(), { + 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])); diff --git a/src/media/web-media.ts b/src/media/web-media.ts index d74f5679e60d..8a695f2cad59 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -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)) {