mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
63a3a958f4
commit
4aafed1f4a
@@ -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
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user