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)) {