---
key: ffmpeg.remote-image-fetcher-bounded
title: 2026-07-20 — Remote graphics-engine images are fetched through a bounded, pooled `IRemoteImageFetcher`; re-fetched per element init, not cached (#511)
status: active
since: '2026-07-20'
supersedes: none
superseded-by: none
rule: 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.
signals: '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'
mechanics: '`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 budget** — `width x height x frames <= 50 MP`, verified against the decoded image.
- **Retention budget** — `frames 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.
