Files
ersatztv/ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.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

120 lines
4.3 KiB
C#

using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg.State;
using Microsoft.Extensions.Logging;
using NCalc;
using SkiaSharp;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public class WatermarkElement : ImageElementBase
{
private readonly string _imagePath;
private readonly ILogger _logger;
private readonly ChannelWatermark _watermark;
private Option<Expression> _maybeOpacityExpression;
private float _opacity;
public WatermarkElement(WatermarkOptions watermarkOptions, IRemoteImageFetcher remoteImageFetcher, ILogger logger)
: base(remoteImageFetcher)
{
_logger = logger;
// TODO: better model coming in here?
_imagePath = watermarkOptions.ImagePath;
_watermark = watermarkOptions.Watermark;
ZIndex = watermarkOptions.Watermark.ZIndex;
DebugKey = $"Watermark {watermarkOptions.Watermark.Name}";
}
public bool IsValid => _imagePath != null && _watermark != null;
public override int ZIndex { get; }
public override string DebugKey { get; }
public override async Task InitializeAsync(GraphicsEngineContext context, CancellationToken cancellationToken)
{
try
{
if (_watermark.Mode is ChannelWatermarkMode.Intermittent)
{
var expressionString = $@"
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < 1,
(time_of_day_seconds % {_watermark.FrequencyMinutes * 60}),
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < {1 + _watermark.DurationSeconds},
1,
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < {1 + _watermark.DurationSeconds + 1},
1 - ((time_of_day_seconds % {_watermark.FrequencyMinutes * 60} - {1 + _watermark.DurationSeconds}) / 1),
0
)
)
) * {_watermark.Opacity / 100.0f}";
_maybeOpacityExpression = new Expression(expressionString);
}
else if (_watermark.Mode is ChannelWatermarkMode.OpacityExpression &&
!string.IsNullOrWhiteSpace(_watermark.OpacityExpression))
{
_maybeOpacityExpression = new Expression(_watermark.OpacityExpression);
}
else
{
_opacity = _watermark.Opacity / 100.0f;
}
foreach (Expression expression in _maybeOpacityExpression)
{
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction;
}
await LoadImage(
context.SquarePixelFrameSize,
context.FrameSize,
_imagePath,
_watermark.Location,
_watermark.Size == WatermarkSize.Scaled,
_watermark.WidthPercent,
_watermark.HorizontalMarginPercent,
_watermark.VerticalMarginPercent,
_watermark.PlaceWithinSourceContent,
cancellationToken);
}
catch (Exception ex)
{
IsFinished = true;
_logger.LogWarning(ex, "Failed to initialize watermark element; will disable for this content");
}
}
public override ValueTask<Option<PreparedElementImage>> PrepareImage(
TimeSpan timeOfDay,
TimeSpan contentTime,
TimeSpan contentTotalTime,
TimeSpan channelTime,
CancellationToken cancellationToken)
{
float opacity = _opacity;
foreach (Expression expression in _maybeOpacityExpression)
{
opacity = OpacityExpressionHelper.GetOpacity(
expression,
timeOfDay,
contentTime,
contentTotalTime,
channelTime);
}
if (opacity == 0)
{
return ValueTask.FromResult(Option<PreparedElementImage>.None);
}
SKBitmap frameForTimestamp = GetFrameForTimestamp(contentTime);
return ValueTask.FromResult(
Optional(new PreparedElementImage(frameForTimestamp, Location, opacity, ZIndex, false)));
}
}