Fourth adversarial pass cleared the security design — all three earlier
bypasses are dead, DecoderOptions.MaxFrames is honored by every decoder
that can produce multiple frames (GIF/WebP/TIFF exactly N, APNG N-1),
and it bounds PEAK allocation, not just the final frame count (measured:
65 MiB capped vs 2.41 GiB uncapped on the same 600-frame GIF).
But it caught a functional regression this PR introduced: a *default*
`Image.IdentifyAsync` throws InvalidImageContentException on most APNGs
that `Image.Load` reads back perfectly — including files ImageSharp's
own PngEncoder wrote. Reproduced independently: 13 of 16 shapes throw,
and `MaxFrames = 1` on the Identify fixes all 16 with dimensions intact.
Since #502 routes ordinary channel-logo watermarks through this path, an
admin with an animated PNG logo would have silently lost their watermark
to a log line — a hardening change breaking working content.
The existing tests could not see it: they use 64x64, which happens to be
one of the few shapes a default Identify handles. Now pinned with a
288x288 shape that asserts the default Identify DOES fail and that
DecodeRemoteImage decodes it anyway, in full.
Also, from the same pass:
- document the REAL enforced peak (up to 3x the nominal 50 MP budget,
since detecting "over the limit" means decoding past it) instead of
restating the nominal number. Tightening the single-frame allowance to
budget/3 would reject legitimate 8K stills, so the overshoot is
deliberate; it is ~600 MB against the ~36 GiB it replaces
- correct the MaxFrames off-by-one claim: N-1 is APNG-specific, not
universal, so the stated rationale for +2 was wrong for three of the
four animated formats
Second adversarial re-review defeated the product budget too, and the
mechanism generalizes: the budget was enforced on a number the decoder
does not honor.
Measured on ImageSharp 3.1.12 (reproduced independently before fixing):
600-frame APNG -> Identify: FrameMetadataCollection.Count = 0
Load: Frames.Count = 600
So EnsureDecodeAffordable(w, h, 0) charged Math.Max(0,1) = 1 frame —
the most permissive possible reading. A 4000x4000 x600 APNG is ~134 KiB
on the wire, is charged 16 MP, and decodes to ~36 GiB: 2.5x worse than
the GIF the previous commit exists to stop, at half the wire size. The
retention budget could not backstop it — that runs after LoadAsync, so
the process OOMs first, killing every concurrent stream.
GIF, WebP and TIFF report honestly; PNG/APNG is the sole divergence,
which is the point: you cannot audit every format, so the header cannot
be the source of truth.
DecodeRemoteImage now:
- checks header DIMENSIONS only (trustworthy; a GIF image descriptor
exceeding its logical screen is clamped by the decoder, verified)
- derives how many frames of that size the budget affords
- passes that to DecoderOptions.MaxFrames, which the DECODER enforces
whatever the header claimed. Measured: MaxFrames = N yields N-1
frames, so it asks for affordable + 2 — decoding one more than allowed
is what distinguishes "at the limit" from "over it" without silently
truncating a legitimate animation
- re-verifies the real image.Frames.Count after decoding, disposing and
rejecting if over
Also adds wiring coverage for the retention budget (M4): deleting its
call site now fails a test — negative-controlled, build verified before
trusting the result.
docs/decisions.md records both failed attempts, because the lesson is
the generalizable part: independent caps do not compose into a budget,
and a limit the decoder does not enforce is not a limit.
Adversarial re-review of the first fix defeated its decode guard with a
measured payload: a 2500x2500 x600-frame GIF is ~60 KiB on the wire,
passes the 50 MP dimension check (6.25 MP) AND the 600-frame check
(exactly 600), and costs ~14 GiB to decode — strictly worse than the
30000x30000 PNG the guard was added to stop, at 1/60th the wire size.
Checking dimensions and frames independently never bounded the decode.
- decode budget is now width x height x frames <= 50 MP, as one product;
a zero frame count is charged as one so an unenumerable header cannot
zero it out
- new retention budget: frames x scaledWidth x scaledHeight <= 200 MP.
Independent of the decode budget in both directions — a 100x100 source
is trivial to decode but retains ~5 GB of SKBitmap once every frame is
scaled to 1920x1080, since LoadImage clones and resizes each frame to
output resolution and keeps them
- both budgets are pure functions (EnsureDecodeAffordable,
EnsureScaledFramesAffordable) so the arithmetic is tested at every
boundary without materializing multi-gigabyte images
- the frame guard had NO coverage before; it does now
- fail loudly on a non-seekable fetcher stream instead of letting
Position throw NotSupportedException into the blanket catch
- test the copy over-read against the ACTUAL rented buffer length
(ArrayPool.Rent(81920) returns 131072), not the requested 81920
docs/decisions.md corrected: it claimed the byte cap bounded the
decode-bomb surface and that the header check closed the class. Both
overstated. An append-only file that is confidently wrong is worse than
one with a gap.
`ImageElementBase.LoadImage` fetched http(s) images with a throwaway
`new HttpClient()` + `GetStreamAsync`: no timeout override (the 100s
default), no size cap, unbounded redirects, no pooling — all inside
stream startup, while ffmpeg waits on the pipe. #502 routed ordinary
channel-logo watermarks onto that path, widening a pre-existing weakness.
Introduce `IRemoteImageFetcher` / `HttpRemoteImageFetcher`, modelled on
the neighbouring `IRemoteStreamProber`:
- deadline covers headers AND body (linked CTS + `CancelAfter`, client
`Timeout = InfiniteTimeSpan`) — under `ResponseHeadersRead` the body
read falls outside `HttpClient.Timeout` (the #289 lesson)
- 10 MiB cap enforced during the copy; `Content-Length` is only a cheap
early reject, since it can be absent or a lie
- permissive content-type check (rejects an HTML error page, allows a
missing type and octet-stream)
- pooled via `IHttpClientFactory`; redirects capped at 3, not 50
A byte cap does NOT bound decoding, so `DecodeRemoteImage` additionally
reads declared dimensions + frame count from the header and rejects
before `Image.LoadAsync` allocates (50 MP / 600 frames). A 4 KB PNG
declaring 30000x30000 costs ~3.6 GB to decode and passes every wire-size
check — caught by adversarial review of the first version of this change,
which capped bytes and wrongly claimed that was decode-bomb protection.
Not cached and SSRF not mitigated — both deliberate, with the reasoning
recorded in docs/decisions.md.
fixes#511