84 lines
4.3 KiB
C#
84 lines
4.3 KiB
C#
using ErsatzTV.Core.Images;
|
|
using ErsatzTV.Core.Interfaces.Images;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.Formats;
|
|
using Image = SixLabors.ImageSharp.Image;
|
|
|
|
namespace ErsatzTV.Infrastructure.Images;
|
|
|
|
public class RemoteImageValidator : IRemoteImageValidator
|
|
{
|
|
public async Task Validate(Stream stream, Uri uri, CancellationToken cancellationToken)
|
|
{
|
|
using Image _ = await DecodeAndValidate(stream, uri, cancellationToken);
|
|
}
|
|
|
|
/// <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 (~3.6 GB), and a 60 KiB GIF can
|
|
/// declare 2500x2500 across 600 frames (~14 GiB). The budget is therefore on the PRODUCT of
|
|
/// dimensions and frames, read 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>
|
|
public static async Task<Image> DecodeAndValidate(Stream stream, Uri uri, CancellationToken cancellationToken)
|
|
{
|
|
if (!stream.CanSeek)
|
|
{
|
|
// Identify consumes the stream, so the decode below needs to rewind it. Fail with the
|
|
// real reason rather than letting Position throw NotSupportedException, which the
|
|
// caller's blanket catch would report as a generic initialization failure.
|
|
throw new InvalidOperationException(
|
|
$"Remote image {uri} was returned on a non-seekable stream; IRemoteImageFetcher must "
|
|
+ "return a fully buffered, seekable stream");
|
|
}
|
|
|
|
// MaxFrames = 1 on the IDENTIFY is not a limit, it is a workaround: a default Identify
|
|
// throws InvalidImageContentException on most APNGs — including files ImageSharp's own
|
|
// PngEncoder wrote, which Image.Load then reads back perfectly (measured: 13 of 16 shapes).
|
|
// Without this, adding the header pre-pass would silently disable every animated-PNG logo
|
|
// that worked before this change. Only Width/Height are read below, and those stay correct.
|
|
ImageInfo info = await Image.IdentifyAsync(
|
|
new DecoderOptions { MaxFrames = 1 },
|
|
stream,
|
|
cancellationToken);
|
|
|
|
// DIMENSIONS from the header are trustworthy; the FRAME COUNT is not, and is deliberately
|
|
// not used as a budget input. Measured on ImageSharp 3.1.12: an APNG reports
|
|
// FrameMetadataCollection.Count == 0 while the decoder happily produces 600 frames, so a
|
|
// header-derived frame budget is enforced on a number the decoder does not honor — a
|
|
// 134 KiB file decodes to ~36 GiB. (Second adversarial re-review; ersatztv#511.)
|
|
RemoteImageDecodeBudget.EnsureDimensionsAffordable(info.Width, info.Height, uri);
|
|
|
|
int affordableFrames = RemoteImageDecodeBudget.AffordableFrames(info.Width, info.Height);
|
|
|
|
stream.Position = 0;
|
|
|
|
// MaxFrames is enforced BY THE DECODER, so it holds whatever the header claimed — measured
|
|
// as honored by every animated decoder here (APNG, GIF, WebP, TIFF). Ask for two more than
|
|
// the budget allows so that an animation exactly AT the limit still decodes in full, while
|
|
// anything over it is present in the decoded image for the post-decode check below to
|
|
// reject. Slop is at most two frames: MaxFrames = N yields N frames for GIF/WebP/TIFF but
|
|
// N-1 for APNG, so the exact count varies by format and only the upper bound matters.
|
|
var decoderOptions = new DecoderOptions { MaxFrames = (uint)(affordableFrames + 2) };
|
|
|
|
Image image = await Image.LoadAsync(decoderOptions, stream, cancellationToken);
|
|
|
|
try
|
|
{
|
|
// re-verify against REALITY rather than against the header. this is the check that
|
|
// actually holds; everything above it only avoids decoding when we can tell in advance.
|
|
RemoteImageDecodeBudget.EnsureDecodeAffordable(image.Width, image.Height, image.Frames.Count, uri);
|
|
return image;
|
|
}
|
|
catch
|
|
{
|
|
image.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
}
|