5616fa6de5ce45b644cb02f4cdcc7d7f712aebf8
4
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5616fa6de5 |
fix(511): don't let the header pre-pass break animated PNG logos
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 15s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m38s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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 |
||
|
|
d4e112f1e9 |
fix(511): bound the DECODER, not the header's frame count
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 15s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m34s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m39s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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.
|
||
|
|
9a2096f340 |
fix(511): budget decode by the PRODUCT, not by independent caps
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. |
||
|
|
e132c422bb |
fix(511): bound remote graphics-engine image fetches
`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 |