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
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:
- 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.
- The second version checked the product, but sourced the frame count from
Image.IdentifyAsync. Measured on ImageSharp 3.1.12: an APNG reportsFrameMetadataCollection.Count == 0while 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 ofSKBitmapat 600 frames scaled to 1920x1080, becauseLoadImageclones 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.