Files
ersatztv/ErsatzTV.Infrastructure.Tests/Streaming/HttpRemoteStreamProberTests.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

165 lines
6.4 KiB
C#

using System.Net;
using ErsatzTV.Infrastructure.Streaming;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Infrastructure.Tests.Streaming;
[TestFixture]
public class HttpRemoteStreamProberTests
{
private const string Url = "http://localhost:8409/media/jellyfin/abc123";
[Test]
public async Task Should_Report_Unavailable_On_404_From_The_Media_Server()
{
// a media-server 404 arrives after our /media/... endpoint redirected, so the response's
// final request uri is the media server's, not the probe url
HttpRemoteStreamProber prober = ProberReturning(
HttpStatusCode.NotFound,
finalUri: "http://jellyfin:8096/Videos/abc123/stream?static=true");
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeFalse();
}
// ersatztv#473 review finding: our OWN /media/{provider}/... endpoint 404s when the media source
// is unconfigured or momentarily missing. Failing closed there would blank every item on that
// source, which is exactly what the fail-open contract exists to prevent.
[Test]
public async Task Should_Fail_Open_On_404_That_Was_Not_Redirected()
{
HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.NotFound, finalUri: Url);
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
}
// a plex key can contain spaces/unicode; pin that an un-redirected 404 on such a url still fails
// OPEN. (This passes against a naive string comparison too - Uri.ToString() unescapes - so it
// guards the behaviour, not the implementation choice.)
[Test]
public async Task Should_Fail_Open_On_404_For_An_Unredirected_Url_Needing_Escaping()
{
const string plexUrl = "http://localhost:8409/media/plex/1/library/parts/1/a file.mkv";
HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.NotFound, finalUri: plexUrl);
bool result = await prober.IsAvailable(plexUrl, CancellationToken.None);
result.ShouldBeTrue();
}
[TestCase(HttpStatusCode.OK)]
[TestCase(HttpStatusCode.PartialContent)]
[TestCase(HttpStatusCode.NoContent)]
public async Task Should_Report_Available_On_Success(HttpStatusCode statusCode)
{
HttpRemoteStreamProber prober = ProberReturning(statusCode);
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
}
// the fail-open contract: a probe that cannot answer must never block a tune that would
// otherwise have worked. these cases exist so a future refactor can't silently invert it.
[TestCase(HttpStatusCode.InternalServerError)]
[TestCase(HttpStatusCode.BadGateway)]
[TestCase(HttpStatusCode.Unauthorized)]
[TestCase(HttpStatusCode.Forbidden)]
public async Task Should_Fail_Open_On_Other_Status_Codes(HttpStatusCode statusCode)
{
HttpRemoteStreamProber prober = ProberReturning(statusCode);
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
}
// a server that ignores `Range: bytes=0-0` answers 200 with the WHOLE FILE. The probe must not
// read it -- buffering a video on the streaming hot path would be far worse than the aborted
// socket the drain was added to avoid. (Review finding against the first fix commit.)
[Test]
public async Task Should_Not_Read_The_Body_When_The_Server_Ignores_The_Range_Request()
{
var body = new TrackingStream(64 * 1024 * 1024);
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(body) };
var prober = new HttpRemoteStreamProber(
new StubHttpClientFactory(new FixedResponseHttpMessageHandler(response)),
Substitute.For<ILogger<HttpRemoteStreamProber>>());
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
body.BytesRead.ShouldBe(0);
}
// the counterpart: when the server DID honour the range, the one byte is read so the connection
// goes back to the pool rather than being aborted
[Test]
public async Task Should_Drain_The_Single_Byte_When_The_Server_Honours_The_Range_Request()
{
var body = new TrackingStream(1);
var response = new HttpResponseMessage(HttpStatusCode.PartialContent)
{
Content = new StreamContent(body)
};
var prober = new HttpRemoteStreamProber(
new StubHttpClientFactory(new FixedResponseHttpMessageHandler(response)),
Substitute.For<ILogger<HttpRemoteStreamProber>>());
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
body.BytesRead.ShouldBe(1);
}
[Test]
public async Task Should_Fail_Open_On_Transport_Failure()
{
var prober = new HttpRemoteStreamProber(
new StubHttpClientFactory(new ThrowingHttpMessageHandler(new HttpRequestException("no route to host"))),
Substitute.For<ILogger<HttpRemoteStreamProber>>());
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
}
[Test]
public async Task Should_Fail_Open_On_Timeout()
{
var prober = new HttpRemoteStreamProber(
new StubHttpClientFactory(new ThrowingHttpMessageHandler(new TaskCanceledException("timed out"))),
Substitute.For<ILogger<HttpRemoteStreamProber>>());
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
}
// caller cancellation (shutdown / client disconnect) is a genuine signal, NOT a probe failure --
// swallowing it would let the handler go on building an ffmpeg command on a dead token.
[Test]
public async Task Should_Propagate_Caller_Cancellation()
{
HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.OK);
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(() => prober.IsAvailable(Url, cts.Token));
}
private static HttpRemoteStreamProber ProberReturning(HttpStatusCode statusCode, string finalUri = null) =>
new(
new StubHttpClientFactory(new StatusCodeHttpMessageHandler(statusCode, finalUri)),
Substitute.For<ILogger<HttpRemoteStreamProber>>());
}