`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
187 lines
6.2 KiB
C#
187 lines
6.2 KiB
C#
using System.Diagnostics;
|
|
using System.Net;
|
|
|
|
namespace ErsatzTV.Infrastructure.Tests.Streaming;
|
|
|
|
/// <summary>
|
|
/// Shared fakes for the HTTP-backed streaming services. Extracted from
|
|
/// <see cref="HttpRemoteStreamProberTests" /> when <see cref="HttpRemoteImageFetcherTests" />
|
|
/// needed the same harness. (ersatztv#511)
|
|
/// </summary>
|
|
internal sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
|
|
{
|
|
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
|
|
}
|
|
|
|
internal sealed class StatusCodeHttpMessageHandler(HttpStatusCode statusCode, string finalUri = null)
|
|
: HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
// HttpClient rewrites RequestMessage.RequestUri to the final hop when it follows a
|
|
// redirect; finalUri lets a test stand in for "the media server answered this".
|
|
if (finalUri is not null)
|
|
{
|
|
request.RequestUri = new Uri(finalUri);
|
|
}
|
|
|
|
return Task.FromResult(new HttpResponseMessage(statusCode) { RequestMessage = request });
|
|
}
|
|
}
|
|
|
|
internal sealed class FixedResponseHttpMessageHandler(HttpResponseMessage response) : HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
response.RequestMessage = request;
|
|
return Task.FromResult(response);
|
|
}
|
|
}
|
|
|
|
/// <summary>A readable stream that records how many bytes were actually pulled from it.</summary>
|
|
internal sealed class TrackingStream(long length) : Stream
|
|
{
|
|
public int BytesRead { get; private set; }
|
|
|
|
public override bool CanRead => true;
|
|
public override bool CanSeek => false;
|
|
public override bool CanWrite => false;
|
|
public override long Length => length;
|
|
|
|
public override long Position
|
|
{
|
|
get => BytesRead;
|
|
set => throw new NotSupportedException();
|
|
}
|
|
|
|
public override int Read(byte[] buffer, int offset, int count)
|
|
{
|
|
if (BytesRead >= length)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
int toRead = (int)Math.Min(count, length - BytesRead);
|
|
Array.Clear(buffer, offset, toRead);
|
|
BytesRead += toRead;
|
|
return toRead;
|
|
}
|
|
|
|
public override void Flush()
|
|
{
|
|
}
|
|
|
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
|
public override void SetLength(long value) => throw new NotSupportedException();
|
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A readable stream that returns headers-worth of data instantly and then drips forever —
|
|
/// the case that <see cref="HttpCompletionOption.ResponseHeadersRead" /> leaves outside
|
|
/// <see cref="HttpClient.Timeout" />.
|
|
/// </summary>
|
|
internal sealed class SlowDripStream : Stream
|
|
{
|
|
public override bool CanRead => true;
|
|
public override bool CanSeek => false;
|
|
public override bool CanWrite => false;
|
|
public override long Length => throw new NotSupportedException();
|
|
|
|
public override long Position
|
|
{
|
|
get => 0;
|
|
set => throw new NotSupportedException();
|
|
}
|
|
|
|
public override async ValueTask<int> ReadAsync(
|
|
Memory<byte> buffer,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await Task.Delay(Timeout.Infinite, cancellationToken);
|
|
throw new UnreachableException();
|
|
}
|
|
|
|
public override int Read(byte[] buffer, int offset, int count) =>
|
|
ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult();
|
|
|
|
public override void Flush()
|
|
{
|
|
}
|
|
|
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
|
public override void SetLength(long value) => throw new NotSupportedException();
|
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
|
}
|
|
|
|
internal sealed class ThrowingHttpMessageHandler(Exception exception) : HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken) =>
|
|
Task.FromException<HttpResponseMessage>(exception);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A handler that never completes until the request is cancelled — stands in for a host that
|
|
/// accepts the connection and then hangs.
|
|
/// </summary>
|
|
internal sealed class HangingHttpMessageHandler : HttpMessageHandler
|
|
{
|
|
protected override async Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await Task.Delay(Timeout.Infinite, cancellationToken);
|
|
throw new UnreachableException();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A readable stream that yields <paramref name="length" /> bytes but only ever a little at a
|
|
/// time, so a size cap has to be enforced during the copy rather than from Content-Length.
|
|
/// </summary>
|
|
internal sealed class ChunkedZeroStream(long length, int chunkSize = 4096) : Stream
|
|
{
|
|
private long _position;
|
|
|
|
public override bool CanRead => true;
|
|
public override bool CanSeek => false;
|
|
public override bool CanWrite => false;
|
|
public override long Length => length;
|
|
|
|
public override long Position
|
|
{
|
|
get => _position;
|
|
set => throw new NotSupportedException();
|
|
}
|
|
|
|
public override int Read(byte[] buffer, int offset, int count)
|
|
{
|
|
if (_position >= length)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
int toRead = (int)Math.Min(Math.Min(count, chunkSize), length - _position);
|
|
Array.Clear(buffer, offset, toRead);
|
|
_position += toRead;
|
|
return toRead;
|
|
}
|
|
|
|
public override void Flush()
|
|
{
|
|
}
|
|
|
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
|
public override void SetLength(long value) => throw new NotSupportedException();
|
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
|
}
|