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); } /// /// Decodes a remote image only after the header says decoding it is affordable. /// /// /// 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) /// public static async Task 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; } } }