Files
ersatztv/docs/decisions/records/ffmpeg/remote-image-fetcher-bounded.md
T
timothy fba5233caf
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(610): split the decision corpus into one YAML-frontmatter file per record
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00

11 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
ffmpeg.remote-image-fetcher-bounded 2026-07-20 — Remote graphics-engine images are fetched through a bounded, pooled `IRemoteImageFetcher`; re-fetched per element init, not cached (#511) active 2026-07-20 none none remote graphics-engine images are fetched through `IRemoteImageFetcher` with a pooled `HttpClientFactory` client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced `DecoderOptions.MaxFrames` bound re-verified post-decode — never cached, re-fetched per element init. remote image fetch, decompression bomb, MaxFrames, decode budget vs retention budget, SSRF accepted risk · paths: `IRemoteImageFetcher`, `HttpRemoteImageFetcher`, `ImageElementBase.LoadImage`, `DecoderOptions.MaxFrames`, `WatermarkElementRemoteImageTests` · issues: #511, #502, #289 `HttpRemoteImageFetcher` (Infrastructure) over `IHttpClientFactory`; `EnsureDecodeAffordable`/`EnsureScaledFramesAffordable` pure-function budget checks

ImageElementBase.LoadImage fetched http(s) images with a throwaway new HttpClient() and GetStreamAsync. That is unbounded in three directions at once — no timeout override (the 100s HttpClient default), no response size cap, and a new connection pool per element — and it runs inside stream startup, while ffmpeg waits on the pipe. #502 routed ordinary channel-logo watermarks onto that path, which is what made a pre-existing weakness worth hardening.

The fetch now lives behind IRemoteImageFetcher (HttpRemoteImageFetcher), deliberately modelled on the neighbouring IRemoteStreamProber: a Core interface, an Infrastructure implementation over IHttpClientFactory, and a CancellationTokenSource.CreateLinkedTokenSource + CancelAfter deadline.

The deadline covers the body, not just the headers. The named client is registered with Timeout = InfiniteTimeSpan and the linked token is threaded into GetAsync and every stream read, because under HttpCompletionOption.ResponseHeadersRead the body read falls outside HttpClient.Timeout — a slow-drip host would otherwise hang forever. Same mechanic as #289.

The size cap is enforced during the copy, not from Content-Length. The advertised length is only a cheap early reject; it can be absent or a lie, so the byte counter in the copy loop is what actually bounds transfer and buffering. (It bounds the wire, not the decode — see below.) Both paths are covered by tests, and both were negative-controlled (disabling the checks fails exactly those two tests).

The byte cap does not bound decoding, so there are two further budgets — and the decode bound is imposed on the DECODER, not read from the header. A decompression bomb is by definition small on the wire: a 4 KB PNG can declare 30000x30000 (~3.6 GB to decode), and a 60 KiB GIF can declare 2500x2500 across 600 frames (~14 GiB). All of that passes Content-Length, the content-type check and the 10 MiB copy cap in milliseconds.

Getting this right took three attempts, and the two failures are the interesting part:

  1. The first version checked dimensions (50 MP) and frame count (600) independently. The 2500x2500 x600 GIF above passes both while costing ~14 GiB. Independent caps do not compose into a budget — the bound has to be on the PRODUCT.
  2. The second version checked the product, but sourced the frame count from Image.IdentifyAsync. Measured on ImageSharp 3.1.12: an APNG reports FrameMetadataCollection.Count == 0 while the decoder produces every frame. A 4000x4000 x600 APNG is ~134 KiB on the wire, is charged as ONE frame (16 MP, comfortably inside the budget) and decodes to ~36 GiB — 2.5x worse than the bomb that version was written to stop. Header-derived limits are advisory; a limit the decoder does not enforce is not a limit. (GIF, WebP and TIFF report honestly; PNG/APNG is the sole divergence, which is exactly why trusting the header is untenable — you cannot audit every format.)

So DecodeRemoteImage now: checks the header dimensions (which are trustworthy — a GIF whose image descriptor exceeds 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 regardless of what the header claimed; and then re-verifies the real image.Frames.Count after decoding, disposing and rejecting if it is over. MaxFrames was measured as honored by every animated decoder in play (APNG, GIF, WebP, TIFF), which is what makes it a real bound rather than another advisory one. The code asks for affordable + 2 so an animation exactly at the limit still decodes in full while anything over it is visible to the post-decode check; the slop is at most two frames, since MaxFrames = N yields N frames for GIF/WebP/TIFF but N-1 for APNG — the exact count varies by format, so only the upper bound is relied on.

  • Decode budgetwidth x height x frames <= 50 MP, verified against the decoded image.
  • Retention budgetframes x scaledWidth x scaledHeight <= 200 MP (~800 MB at 4 bytes/px), checked once the scale is known. Independent of the decode budget in both directions: a 100x100 source is trivial to decode but retains ~5 GB of SKBitmap at 600 frames scaled to 1920x1080, because LoadImage clones and resizes every frame to output resolution and keeps them.

The enforced peak is up to 3x the nominal decode budget, and that is deliberate. Detecting "over the limit" requires actually decoding more frames than the limit allows, so the ceiling is (affordable + 2) x perFrame. In the pathological case — one frame that alone fills the budget, so affordable = 1 — that is 150 MP (~600 MB at Rgba32, ~1.2 GB for a 16-bit TIFF at Rgba64) rather than 50 MP. Bounded and survivable, against the ~36 GiB it replaces, and the alternative (tightening the single-frame allowance to budget/3) would reject legitimate 8K stills at 33 MP. Stated here because the previous three versions of this entry each claimed a bound the code did not actually enforce.

Other caveats: the budgets are in pixels, but a 16-bit PNG decodes to Rgba64 (8 B/px), so the byte cost doubles; the retention budget is per element, with no global ceiling across concurrent streams; and 200 MP caps a full-frame 1080p animated overlay at ~96 frames (~3.2s at 30fps), which is the one limit here that could plausibly bite a legitimate user rather than an attacker.

A workaround rides along with the header pre-pass. Image.IdentifyAsync is called with MaxFrames = 1 — not as a limit, but because a default Identify throws InvalidImageContentException on most APNGs (measured: 13 of 16 shapes, including files ImageSharp's own PngEncoder wrote) that Image.Load reads back perfectly. Adding the pre-pass without it would have silently disabled every animated-PNG logo that worked before this change — a functional regression introduced by a hardening change, caught only because the reviewer swept shapes rather than trusting the one the tests happened to use.

Both budgets are enforced by pure functions (EnsureDecodeAffordable, EnsureScaledFramesAffordable) so the arithmetic is tested at every boundary without materializing multi-gigabyte images, and both call sites have wiring coverage (deleting either one fails a test). The APNG case is pinned by a regression test that asserts the header under-reports and that the decode is rejected anyway. Local images are deliberately exempt: those are files an operator put on disk, not bytes an arbitrary host returned.

Content type is checked permissively. A positively-not-an-image type (an HTML error page) is rejected before the decoder sees it, but a missing type and application/octet-stream are allowed — hosts omit the header and static file servers default to octet-stream often enough that strictness would break working logos, while buying little: ImageSharp decodes by magic bytes, so the size and dimension caps are what actually protect the decoder.

Redirects stay enabled but capped at 3 (the default is 50). Logo hosts and CDNs legitimately redirect, so disabling them would break real configurations.

Not cached — re-fetched on each element initialization, i.e. per playout item. #502's entry noted that a fetch-once-into-the-image-cache design would also erase its per-frame cost asymmetry, so caching was considered here and rejected for now: with pooling, a 10s ceiling and a 10 MiB cap, one small GET per item transition is not a cost worth a cache's invalidation policy (when does an admin's logo change take effect?) and lifetime questions. It also keeps the codebase-wide "external artwork passes through, it is not downloaded into the image cache" convention #502 established intact. Revisit only with a measurement showing the re-fetch actually costs something.

SSRF is accepted, not mitigated. ChannelValidations.ValidateLogo still only checks the scheme, so an admin-set logo URL remains a request origin inside the container's network, and the redirect cap bounds hop count, not destination — an approved host can still redirect hop three to 169.254.169.254 or 127.0.0.1:<port>. This is deliberate, and the load-bearing reason is that the primitive is blind: the response body is never returned to any user, only decoded and composited into a video frame, and failures surface only as a log line. Combined with the capability being admin-only — and with LAN-hosted logos being a legitimate, common setup here, so a private-IP denylist would break working installs — the exposure does not justify the breakage. Revisit if logo URLs ever become settable through a lower-privilege path, or if any fetch result becomes readable by a caller; note that "admin-only" is the weaker half of this argument and blindness is the stronger.

Failures are surfaced as exceptions rather than a failure value, because both call sites (WatermarkElement, ImageElement) already wrap initialization in a catch that sets IsFinished — so a dead, slow or oversized URL degrades to "element disabled" and never kills the stream. WatermarkElementRemoteImageTests pins that contract, including that a local path never touches the fetcher.

The fetcher distinguishes its own deadline (rethrown as TimeoutException) from caller cancellation (propagated as OperationCanceledException) via an exception filter on cancellationToken.IsCancellationRequested. Be aware this distinction is currently observationally inert: the elements' pre-existing blanket catch (Exception) swallows both one frame up, so a shutdown mid-tune logs a spurious per-element warning. The filter is kept because it makes the fetcher correct on its own terms and the log message names the real cause; making the elements re-throw cancellation is a separate, pre-existing concern.