Harden remote-image fetching in the graphics engine (timeout, size cap, redirects, pooling, caching, SSRF) #511
Closed
opened 2026-07-20 22:46:02 +02:00 by timothy
·
2 comments
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
renovate/nuget-sixlabors.imagesharp-vulnerability
913-scripted-playout-401
929-vuln-pkcs-imagesharp-v4
renovate/dotnet-monorepo
release/v26.15.0-notes
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.16.0
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
No labels
priority: medium
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#511
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Split out of #502 (found by that PR's adversarial review). Pre-existing behavior, but #502 routes ordinary channel-logo watermarks onto this path, so it is now on a materially wider surface and deserves its own hardening pass.
Where
ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElementBase.cs(~:47-58):Problems
HttpClientdefault. A slow-drip or hung host stalls graphics-engine init while ffmpeg waits on the pipe. Cold-start sensitive (cf. #350).MaxResponseContentBufferSize, no content-length/content-type check beforeImage.LoadAsync. Decompression-bomb surface.new HttpClient()per element, not pooled — the classic socket-exhaustion pattern; should useIHttpClientFactory, asArtworkControlleralready does for Plex/Jellyfin/Emby artwork.ChannelValidations.ValidateLogoonly checks the scheme is http/https — no host/IP restriction. An admin-set logo URL is a request origin inside the container's network. Admin-only, so low severity, but it is a real capability.Existing mitigation worth preserving:
WatermarkElement.InitializeAsyncwrapsLoadImagein try/catch and disables the watermark on failure, so a dead URL degrades gracefully rather than killing the stream.Note (4) and (5) together are the argument for the alternative #502 rejected — fetching once into the image cache via
IImageCache.SaveArtworkToCache(Stream, ArtworkKind). If this issue lands that way, it also removes the per-frame-cost asymmetry noted in #502's decision entry.Done-when
SendAsync— see theResponseHeadersReadlesson indocs/decisions.mdfrom #289)IHttpClientFactoryAddressed by PR #518. Five rounds of independent adversarial review; rounds 2–4 returned BLOCKED and found three real decode-bomb bypasses plus one functional regression, round 5 returned
MERGEABLE @ 5616fa6d. Full write-up in the PR.Scope grew beyond the original checklist in one respect worth noting: the byte cap alone does not bound decoding, so the fix also had to budget the decode itself — and, after two failed attempts, to impose that bound on the decoder (
DecoderOptions.MaxFrames+ post-decode re-verification) rather than on header-reported values, which lie.Claiming this (Claude Code session, 2026-07-20). Selector ranked it top eligible:
priority: medium, deps clear, no arc/milestone/review candidates above it.Plan: read #502's decision entry + the existing
IHttpClientFactoryusage inArtworkControllerfirst, then decide caching-vs-refetch and redirect/host policy before touchingImageElementBase.Closing — merged in PR #518
What was done
ImageElementBase.LoadImagefetched remote images with a throwawaynew HttpClient()+GetStreamAsync— unbounded in timeout, size, redirects and pooling, all inside stream startup while ffmpeg waits on the pipe. Replaced withIRemoteImageFetcher/HttpRemoteImageFetcher, modelled on the neighbouringIRemoteStreamProberso it follows an existing convention rather than inventing one.GetAsyncand every stream read, clientTimeout = InfiniteTimeSpanset on the instance (not only in DI, so dropping the registration can't silently widen it to 100s). The #289ResponseHeadersReadlesson.Content-Lengthis only a cheap early reject since it can be absent or lie.width × height × frames ≤ 50 MP, imposed viaDecoderOptions.MaxFramesand re-verified against the decoded image.frames × scaledWidth × scaledHeight ≤ 200 MP, since every frame is scaled to output resolution and retained.IHttpClientFactory; redirects capped at 3 (default 50).Root cause
Two distinct causes, worth separating:
The original defect was pre-existing convenience code —
new HttpClient()inline — that was fine while it only ran for hand-configured image elements. #502 routed ordinary channel-logo watermarks onto the same path, which is what turned a latent weakness into a live one. The lesson is that widening a path's audience is itself a change that deserves a hardening review, even when the path's code doesn't change.Why it took five review rounds is the more useful root cause: my first three attempts all bounded something other than what actually allocates. A byte cap bounds the wire, not the decode. Independent dimension and frame caps bound neither, because the cost is their product. A product computed from header values bounds nothing at all, because headers lie — ImageSharp's
Identifyreports 0 frames for an APNG the decoder then expands to 600. A limit the decoder does not enforce is not a limit.Files changed
ErsatzTV.Core/Interfaces/Streaming/IRemoteImageFetcher.cs(new)ErsatzTV.Infrastructure/Streaming/HttpRemoteImageFetcher.cs(new)ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElementBase.cs— decode/retention budgetsErsatzTV.Infrastructure/Streaming/Graphics/{GraphicsEngine,Image/ImageElement,Image/WatermarkElement}.cs— threadingErsatzTV/Startup.cs— DI + named client with the redirect capHttpRemoteImageFetcherTests,RemoteImageDecodeLimitTests,WatermarkElementRemoteImageTests,HttpTestDoubles(extracted fromHttpRemoteStreamProberTests)docs/decisions.mdDeliberately deferred
catch (Exception), so the fetcher'sTimeoutException/OperationCanceledExceptiondistinction only sharpens the log message today. Pre-existing, out of scope, documented.Follow-ups created
None. One informational note recorded in the PR: multi-page TIFF safety is inherited from ImageSharp's hard
"Images with different sizes are not supported"— worth re-checking on an ImageSharp bump, but there's nothing actionable today.Docs updated
docs/decisions.md— new entry recording the design, both failed attempts, and the accepted residual risks. Documenting the failures was deliberate: the generalizable lesson is more valuable than the final code, and two earlier versions of that entry claimed bounds the code didn't enforce. No route/endpoint/SPA changes, so no other doc was owed.