Files
ersatztv/ErsatzTV.Infrastructure/Streaming/HttpRemoteImageFetcher.cs
T
timothy 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
2026-07-21 01:04:25 +02:00

158 lines
6.9 KiB
C#

using System.Buffers;
using ErsatzTV.Core.Interfaces.Streaming;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
namespace ErsatzTV.Infrastructure.Streaming;
/// <summary>
/// Fetches a remote graphics-engine image over HTTP with a bounded timeout and a bounded size.
/// </summary>
/// <remarks>
/// This runs during graphics-engine element initialization, which happens inside stream startup
/// while ffmpeg waits on the pipe — so an unbounded fetch stalls the tune. Every failure here is
/// surfaced as an exception and the calling element disables itself. (ersatztv#511)
/// </remarks>
public class HttpRemoteImageFetcher : IRemoteImageFetcher
{
/// <summary>Named <see cref="HttpClient" /> configured with the redirect cap in Startup.</summary>
public const string HttpClientName = "RemoteImage";
/// <summary>
/// Covers the whole exchange — connect, headers AND body — because the body read happens
/// outside <see cref="HttpClient.Timeout" /> under <see cref="HttpCompletionOption.ResponseHeadersRead" />,
/// so a slow-drip host would otherwise hang forever. (docs/decisions.md, #289)
/// </summary>
internal static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(10);
/// <summary>
/// Wire-size cap. A channel logo or overlay is orders of magnitude smaller than this.
/// This bounds transfer and buffering only — it is NOT decode-bomb protection, since a bomb
/// is by definition small on the wire. That check lives in
/// <c>ImageElementBase.DecodeRemoteImage</c>, which reads the declared dimensions and frame
/// count from the header before the decoder allocates.
/// </summary>
internal const long MaxImageBytes = 10 * 1024 * 1024;
private readonly TimeSpan _fetchTimeout;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<HttpRemoteImageFetcher> _logger;
private readonly RecyclableMemoryStreamManager _memoryStreamManager;
public HttpRemoteImageFetcher(
IHttpClientFactory httpClientFactory,
RecyclableMemoryStreamManager memoryStreamManager,
ILogger<HttpRemoteImageFetcher> logger)
: this(httpClientFactory, memoryStreamManager, logger, DefaultFetchTimeout)
{
}
// the timeout is only parameterized so a test can prove the deadline fires without waiting the
// real ten seconds; production always goes through the constructor above.
internal HttpRemoteImageFetcher(
IHttpClientFactory httpClientFactory,
RecyclableMemoryStreamManager memoryStreamManager,
ILogger<HttpRemoteImageFetcher> logger,
TimeSpan fetchTimeout)
{
_httpClientFactory = httpClientFactory;
_memoryStreamManager = memoryStreamManager;
_logger = logger;
_fetchTimeout = fetchTimeout;
}
public async Task<Stream> Fetch(Uri uri, CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(_fetchTimeout);
try
{
using HttpClient client = _httpClientFactory.CreateClient(HttpClientName);
// set here, not only in the DI registration, so the deadline cannot silently widen to
// HttpClient's 100s default if that registration is ever dropped or reordered. the
// factory hands back a fresh wrapper each call, so mutating it is safe.
client.Timeout = Timeout.InfiniteTimeSpan;
using HttpResponseMessage response = await client.GetAsync(
uri,
HttpCompletionOption.ResponseHeadersRead,
timeoutCts.Token);
response.EnsureSuccessStatusCode();
string mediaType = response.Content.Headers.ContentType?.MediaType;
if (!IsAcceptableMediaType(mediaType))
{
throw new InvalidOperationException(
$"Remote image {uri} returned content type '{mediaType}', which is not an image");
}
// the advertised length is a cheap early reject; it is not trusted, because it can be
// absent or a lie. the copy below is what actually enforces the cap.
long? contentLength = response.Content.Headers.ContentLength;
if (contentLength > MaxImageBytes)
{
throw new InvalidOperationException(
$"Remote image {uri} advertises {contentLength} bytes, over the {MaxImageBytes} byte limit");
}
await using Stream source = await response.Content.ReadAsStreamAsync(timeoutCts.Token);
return await CopyCapped(source, uri, timeoutCts.Token);
}
catch (OperationCanceledException canceled) when (!cancellationToken.IsCancellationRequested)
{
// OUR deadline fired, not the caller's. re-thrown as a TimeoutException so the element's
// warning names the real cause; caller cancellation (shutdown / client disconnect) still
// propagates as OperationCanceledException.
_logger.LogWarning(
"Timed out after {Seconds}s fetching remote image {Uri}",
_fetchTimeout.TotalSeconds,
uri);
throw new TimeoutException($"Timed out fetching remote image {uri}", canceled);
}
}
// a missing content type is allowed (some hosts omit it) and octet-stream is allowed (a common
// default for statically served files). anything else that is positively NOT an image -- an html
// error page, say -- is rejected before it reaches the decoder.
private static bool IsAcceptableMediaType(string mediaType) =>
string.IsNullOrWhiteSpace(mediaType)
|| mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
|| mediaType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase);
private async Task<Stream> CopyCapped(Stream source, Uri uri, CancellationToken cancellationToken)
{
MemoryStream buffer = _memoryStreamManager.GetStream(nameof(HttpRemoteImageFetcher));
byte[] chunk = ArrayPool<byte>.Shared.Rent(81920);
try
{
int read;
while ((read = await source.ReadAsync(chunk.AsMemory(), cancellationToken)) > 0)
{
if (buffer.Length + read > MaxImageBytes)
{
throw new InvalidOperationException(
$"Remote image {uri} exceeds the {MaxImageBytes} byte limit");
}
await buffer.WriteAsync(chunk.AsMemory(0, read), cancellationToken);
}
}
catch
{
await buffer.DisposeAsync();
throw;
}
finally
{
ArrayPool<byte>.Shared.Return(chunk);
}
buffer.Position = 0;
return buffer;
}
}