`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
158 lines
6.9 KiB
C#
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;
|
|
}
|
|
}
|