`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
233 lines
8.3 KiB
C#
233 lines
8.3 KiB
C#
using System.Runtime.InteropServices;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Interfaces.Streaming;
|
|
using ErsatzTV.FFmpeg.State;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.Formats;
|
|
using SixLabors.ImageSharp.Formats.Gif;
|
|
using SixLabors.ImageSharp.Formats.Png;
|
|
using SixLabors.ImageSharp.Formats.Webp;
|
|
using SixLabors.ImageSharp.Metadata;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
using SixLabors.ImageSharp.Processing;
|
|
using SkiaSharp;
|
|
using Image = SixLabors.ImageSharp.Image;
|
|
|
|
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
|
|
|
|
public abstract class ImageElementBase(IRemoteImageFetcher remoteImageFetcher) : GraphicsElement, IDisposable
|
|
{
|
|
/// <summary>Decoded-pixel ceiling for a remote image. 8K is ~33 MP, so this is generous.</summary>
|
|
internal const long MaxRemotePixels = 50_000_000;
|
|
|
|
/// <summary>
|
|
/// Frame ceiling for a remote animation. Every frame is scaled to frame size and retained,
|
|
/// so the real cost is frames x output resolution, not the wire size.
|
|
/// </summary>
|
|
internal const int MaxRemoteFrames = 600;
|
|
|
|
private readonly List<double> _frameDelays = [];
|
|
private readonly List<SKBitmap> _scaledFrames = [];
|
|
private double _animatedDurationSeconds;
|
|
private ushort _repeatCount;
|
|
|
|
private Image _sourceImage;
|
|
|
|
protected SKPointI Location { get; private set; }
|
|
|
|
public virtual void Dispose()
|
|
{
|
|
GC.SuppressFinalize(this);
|
|
_sourceImage?.Dispose();
|
|
_scaledFrames?.ForEach(f => f.Dispose());
|
|
}
|
|
|
|
protected async Task LoadImage(
|
|
Resolution squarePixelFrameSize,
|
|
Resolution frameSize,
|
|
string image,
|
|
WatermarkLocation location,
|
|
bool scale,
|
|
double? scaleWidthPercent,
|
|
double? horizontalMarginPercent,
|
|
double? verticalMarginPercent,
|
|
bool placeWithinSourceContent,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
bool isRemoteUri = Uri.TryCreate(image, UriKind.Absolute, out Uri uriResult)
|
|
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
|
|
|
|
if (isRemoteUri)
|
|
{
|
|
await using Stream imageStream = await remoteImageFetcher.Fetch(uriResult, cancellationToken);
|
|
_sourceImage = await DecodeRemoteImage(imageStream, uriResult, cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
_sourceImage = await Image.LoadAsync(image!, cancellationToken);
|
|
}
|
|
|
|
int scaledWidth = _sourceImage.Width;
|
|
int scaledHeight = _sourceImage.Height;
|
|
if (scale)
|
|
{
|
|
scaledWidth = (int)Math.Round((scaleWidthPercent ?? 100) / 100.0 * frameSize.Width);
|
|
double aspectRatio = (double)_sourceImage.Height / _sourceImage.Width;
|
|
scaledHeight = (int)(scaledWidth * aspectRatio);
|
|
}
|
|
|
|
(int horizontalMargin, int verticalMargin) = placeWithinSourceContent
|
|
? SourceContentMargins(
|
|
squarePixelFrameSize,
|
|
frameSize,
|
|
horizontalMarginPercent ?? 0,
|
|
verticalMarginPercent ?? 0)
|
|
: NormalMargins(frameSize, horizontalMarginPercent ?? 0, verticalMarginPercent ?? 0);
|
|
|
|
Location = CalculatePosition(
|
|
location,
|
|
frameSize.Width,
|
|
frameSize.Height,
|
|
scaledWidth,
|
|
scaledHeight,
|
|
horizontalMargin,
|
|
verticalMargin);
|
|
|
|
if (_sourceImage.Metadata.DecodedImageFormat == GifFormat.Instance)
|
|
{
|
|
_repeatCount = _sourceImage.Metadata.GetFormatMetadata(GifFormat.Instance).RepeatCount;
|
|
}
|
|
|
|
_animatedDurationSeconds = 0;
|
|
|
|
for (var i = 0; i < _sourceImage.Frames.Count; i++)
|
|
{
|
|
Image frame = _sourceImage.Frames.CloneFrame(i);
|
|
frame.Mutate(ctx => ctx.Resize(scaledWidth, scaledHeight));
|
|
_scaledFrames.Add(ToSkiaBitmap(frame));
|
|
|
|
double frameDelay = GetFrameDelaySeconds(_sourceImage, i);
|
|
_animatedDurationSeconds += frameDelay;
|
|
_frameDelays.Add(frameDelay);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decodes a remote image only after the header says decoding it is affordable.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The fetcher's byte cap does NOT bound this: a decompression bomb is small on the wire and
|
|
/// huge in memory. A 4 KB PNG can declare 30000x30000 and cost ~3.6 GB to decode, and a
|
|
/// small animation can declare thousands of frames, each of which this class then scales to
|
|
/// frame size and keeps. Both are checked from the header, before the decoder allocates.
|
|
/// Local images are deliberately not checked — they are files an operator put on disk, not
|
|
/// bytes an arbitrary host returned. (ersatztv#511)
|
|
/// </remarks>
|
|
internal static async Task<Image> DecodeRemoteImage(Stream stream, Uri uri, CancellationToken cancellationToken)
|
|
{
|
|
ImageInfo info = await Image.IdentifyAsync(stream, cancellationToken);
|
|
|
|
long pixels = (long)info.Width * info.Height;
|
|
if (pixels > MaxRemotePixels)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Remote image {uri} is {info.Width}x{info.Height} ({pixels} pixels), over the "
|
|
+ $"{MaxRemotePixels} pixel limit");
|
|
}
|
|
|
|
int frameCount = info.FrameMetadataCollection.Count;
|
|
if (frameCount > MaxRemoteFrames)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Remote image {uri} has {frameCount} frames, over the {MaxRemoteFrames} frame limit");
|
|
}
|
|
|
|
stream.Position = 0;
|
|
return await Image.LoadAsync(stream, cancellationToken);
|
|
}
|
|
|
|
protected static SKBitmap ToSkiaBitmap(Image image)
|
|
{
|
|
using Image<Rgba32> rgbaImage = image.CloneAs<Rgba32>();
|
|
|
|
int width = rgbaImage.Width;
|
|
int height = rgbaImage.Height;
|
|
|
|
var info = new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Unpremul);
|
|
var skBitmap = new SKBitmap(info);
|
|
if (!skBitmap.TryAllocPixels(info))
|
|
{
|
|
skBitmap.Dispose();
|
|
throw new InvalidOperationException("Failed to allocate pixels for SKBitmap.");
|
|
}
|
|
|
|
var pixelArray = new Rgba32[width * height];
|
|
rgbaImage.CopyPixelDataTo(pixelArray);
|
|
|
|
var bytes = new byte[pixelArray.Length * 4];
|
|
MemoryMarshal.AsBytes(pixelArray.AsSpan()).CopyTo(bytes);
|
|
|
|
IntPtr dstPtr = skBitmap.GetPixels(out _);
|
|
Marshal.Copy(bytes, 0, dstPtr, bytes.Length);
|
|
|
|
return skBitmap;
|
|
}
|
|
|
|
protected static double GetFrameDelaySeconds(Image image, int frameIndex)
|
|
{
|
|
IImageFormat format = image.Metadata.DecodedImageFormat;
|
|
ImageFrameMetadata frameMeta = image.Frames[frameIndex].Metadata;
|
|
|
|
if (format == GifFormat.Instance)
|
|
{
|
|
// GIF frame delay is in hundredths of a second
|
|
GifFrameMetadata gifMeta = frameMeta.GetFormatMetadata(GifFormat.Instance);
|
|
return gifMeta.FrameDelay / 100.0;
|
|
}
|
|
|
|
if (format == PngFormat.Instance)
|
|
{
|
|
// PNG animated frame delay is in seconds (as double)
|
|
PngFrameMetadata pngMeta = frameMeta.GetFormatMetadata(PngFormat.Instance);
|
|
return pngMeta.FrameDelay.ToDouble();
|
|
}
|
|
|
|
if (format == WebpFormat.Instance)
|
|
{
|
|
// WEBP animated frame delay is in milliseconds
|
|
WebpFrameMetadata webpMeta = frameMeta.GetFormatMetadata(WebpFormat.Instance);
|
|
return webpMeta.FrameDelay / 1000.0;
|
|
}
|
|
|
|
// Default: assume 1/60th second (~16.67 ms) if unknown
|
|
return 1.0 / 60.0;
|
|
}
|
|
|
|
protected SKBitmap GetFrameForTimestamp(TimeSpan timestamp)
|
|
{
|
|
if (_scaledFrames.Count <= 1)
|
|
{
|
|
return _scaledFrames[0];
|
|
}
|
|
|
|
if (_repeatCount > 0 && timestamp.TotalSeconds >= _animatedDurationSeconds * _repeatCount)
|
|
{
|
|
return _scaledFrames.Last();
|
|
}
|
|
|
|
double currentTime = timestamp.TotalSeconds % _animatedDurationSeconds;
|
|
|
|
double frameTime = 0;
|
|
for (var i = 0; i < _sourceImage.Frames.Count; i++)
|
|
{
|
|
frameTime += _frameDelays[i];
|
|
if (currentTime <= frameTime)
|
|
{
|
|
return _scaledFrames[i];
|
|
}
|
|
}
|
|
|
|
return _scaledFrames.Last();
|
|
}
|
|
}
|