using System.Buffers.Binary; using ErsatzTV.Infrastructure.Images; using NUnit.Framework; using Shouldly; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Bmp; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Tiff; using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Metadata.Profiles.Icc; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using Image = SixLabors.ImageSharp.Image; namespace ErsatzTV.Infrastructure.Tests.Images; /// /// ImageSharp 3.1.12's TIFF decoder carries GHSA-v76p-62qx-wwq2 and GHSA-wmxv-xphr-5c9g, and the /// patched releases need a Six Labors license key to build in Release, so every ImageSharp decode /// goes through a configuration that cannot detect TIFF. (ersatztv#929, /// security.imagesharp-3x-tiff-decoder-disabled) /// [TestFixture] public class ImageSharpDecodingTests { private static readonly Uri Uri = new("https://example.com/logo.tif"); // the list is derived from Configuration.Default, not restated: a package bump that adds or // drops a format reddens this instead of silently changing what ErsatzTV can decode [Test] public void Configuration_Registers_Every_Default_Format_Except_Tiff() { var expected = Configuration.Default.ImageFormats .Where(f => f is not TiffFormat) .Select(f => f.Name) .OrderBy(n => n, StringComparer.Ordinal) .ToList(); var actual = ImageSharpDecoding.Configuration.ImageFormats .Select(f => f.Name) .OrderBy(n => n, StringComparer.Ordinal) .ToList(); actual.ShouldBe(expected); } // the exclusion above is only meaningful while Default really does carry TIFF, and building // the restricted configuration must not have removed it from the process-wide Default [Test] public void Configuration_Default_Still_Registers_Tiff() { _ = ImageSharpDecoding.Configuration; Configuration.Default.ImageFormats.ShouldContain(f => f is TiffFormat); } // positive control for the rejections below: the fixture is a TIFF ImageSharp can decode, // so a rejection is the configuration's doing and not a broken fixture [Test] public async Task Tiff_Fixture_Decodes_Under_The_Default_Configuration() { await using MemoryStream stream = await RealTiff(64, 32); using Image image = await Image.LoadAsync(stream); image.Width.ShouldBe(64); image.Height.ShouldBe(32); } // the validator as a whole (render path, save path and upload path). The Identify pass rejects // a TIFF before the Load pass runs, so this exercises Identify; that the Load pass takes the // same options is held by ErsatzTV.Architecture.Tests' ImageSharpSurfaceTests. [Test] public async Task DecodeAndValidate_Rejects_A_Tiff_As_An_Unknown_Format() { await using MemoryStream stream = await RealTiff(64, 32); await Should.ThrowAsync( () => RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None)); } // pins the IDENTIFY pass separately. The header declares 30000x30000: an Identify that could // read TIFF would report those dimensions and the budget check would throw // InvalidOperationException("pixel limit") instead, so only a TIFF-blind Identify yields // UnknownImageFormatException here. The IFD walk Identify performs is where the BigTIFF // non-progressing loop (GHSA-wmxv-xphr-5c9g) lives, so the header pass must not reach it. [Test] public async Task DecodeAndValidate_Rejects_A_Tiff_Before_Reading_Its_Header() { await using MemoryStream stream = await TiffDeclaring(30000, 30000); ImageInfo info = await Image.IdentifyAsync(stream); info.Width.ShouldBe(30000); stream.Position = 0; await Should.ThrowAsync( () => RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None)); } // --- GHSA-gwg2-r3hj-4w44: the ICC CLUT parser allocates from unvalidated grid dimensions --- // // ErsatzTV never reads ICC entries (ImageSharpSurfaceTests bans the namespace), but decode, // CloneFrame, Resize and CloneAs all carry the profile along. Whether any of them parses it is // ImageSharp's own runtime behaviour, which an IL scan of ErsatzTV cannot see, so it is pinned // here by allocation. A version that parses the profile on decode or clone reddens these // instead of reopening the advisory silently. private const long HostileIccEntriesFloor = 64L * 1024 * 1024; internal const long ProductPathCeiling = 16L * 1024 * 1024; internal static readonly string[] IccCarriers = ["bmp", "jpeg", "png", "webp"]; // the carrier list is checked, not trusted: every format the decode configuration registers is // round-tripped with the hostile profile through its default encoder, and the formats whose // decode still carries a profile must be exactly IccCarriers. A version that adds ICC support to // another format reddens here. A round trip cannot see a decoder that reads a profile its own // encoder never writes; on 3.1.12 the decoders that construct an IccProfile are BMP, JPEG, PNG, // WebP and TIFF (Cecil scan of the package, 2026-10-28), so none is missed today. [Test] public async Task Icc_Carriers_Are_Every_Configured_Format_That_Round_Trips_A_Profile() { var carriers = new List(); foreach (IImageFormat format in ImageSharpDecoding.Configuration.ImageFormats) { IImageEncoder encoder = Configuration.Default.ImageFormatsManager.GetEncoder(format); await using MemoryStream stream = await WithHostileIcc(encoder); using Image image = await Image.LoadAsync(ImageSharpDecoding.Options(), stream); if (image.Metadata.IccProfile is not null) { carriers.Add(format.Name.ToLowerInvariant()); } } carriers.Order(StringComparer.Ordinal).ShouldBe(IccCarriers.Order(StringComparer.Ordinal)); } // positive control: the fixture really is hostile. Reading Entries runs the CLUT reader on the // declared 255-point, 3-input grid (about 127 MiB on 3.1.12, measured 2026-10-28); the catch // only tolerates the truncated table throwing afterwards. [TestCaseSource(nameof(IccCarriers))] [NonParallelizable] public async Task Hostile_Icc_Fixture_Allocates_When_Its_Entries_Are_Parsed(string format) { await using MemoryStream stream = await WithHostileIcc(format); using Image image = await Image.LoadAsync(ImageSharpDecoding.Options(), stream); IccProfile profile = image.Metadata.IccProfile.ShouldNotBeNull(); long allocated = AllocatedDuring(() => { try { _ = profile.Entries; } catch (Exception) { // the allocation is what is measured, not whether the truncated table throws } }); TestContext.Out.WriteLine($"{format}: reading Entries allocated {allocated / 1024} KiB"); allocated.ShouldBeGreaterThan(HostileIccEntriesFloor); } // the product path: the validator's two decodes, then the per-frame CloneFrame + Resize + // CloneAs that ImageElementBase runs. The profile must survive the path (so the bound is not // vacuous because a decoder dropped it) and nothing on it may size the CLUT. [TestCaseSource(nameof(IccCarriers))] [NonParallelizable] public async Task Decode_Clone_And_Resize_Do_Not_Parse_A_Hostile_Icc_Profile(string format) { await using MemoryStream stream = await WithHostileIcc(format); long allocated = await AllocatedDuringAsync(async () => { using Image image = await RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None); image.Metadata.IccProfile.ShouldNotBeNull(); using Image frame = image.Frames.CloneFrame(0); frame.Mutate(ctx => ctx.Resize(8, 8)); frame.Metadata.IccProfile.ShouldNotBeNull(); using Image rgba = frame.CloneAs(); rgba.Metadata.IccProfile.ShouldNotBeNull(); }); TestContext.Out.WriteLine($"{format}: decode, clone and resize allocated {allocated / 1024} KiB"); allocated.ShouldBeLessThan(ProductPathCeiling); } /// /// A 16x16 image in carrying an ICC profile whose A2B0 tag is an /// mft2 (lut16) with 3 inputs, 15 outputs and 255 grid points, truncated before the /// CLUT: a 208-byte profile. /// internal static Task WithHostileIcc(string format) => WithHostileIcc( format switch { "bmp" => new BmpEncoder(), "jpeg" => new JpegEncoder(), "png" => new PngEncoder(), "webp" => new WebpEncoder { FileFormat = WebpFileFormatType.Lossless }, _ => throw new ArgumentOutOfRangeException(nameof(format), format, null) }); private static async Task WithHostileIcc(IImageEncoder encoder) { using var image = new Image(16, 16); image.Metadata.IccProfile = new IccProfile(HostileIccBytes()); var stream = new MemoryStream(); await image.SaveAsync(stream, encoder); stream.Position = 0; return stream; } private static byte[] HostileIccBytes() { // mft2: type, reserved, in/out/grid/pad, 3x3 matrix, input/output table entry counts, // then the three 2-entry input tables, and no CLUT var tag = new List(); tag.AddRange("mft2"u8.ToArray()); tag.AddRange(new byte[4]); tag.AddRange([3, 15, 255, 0]); for (var i = 0; i < 9; i++) { tag.AddRange(BigEndian(i % 4 == 0 ? 0x00010000u : 0u)); } tag.AddRange([0, 2, 0, 2]); tag.AddRange(new byte[3 * 2 * 2]); const int TagOffset = 128 + 4 + 12; int size = TagOffset + tag.Count; var bytes = new byte[size]; BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(0), (uint)size); BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(8), 0x02100000u); "mntr"u8.CopyTo(bytes.AsSpan(12)); "RGB "u8.CopyTo(bytes.AsSpan(16)); "XYZ "u8.CopyTo(bytes.AsSpan(20)); "acsp"u8.CopyTo(bytes.AsSpan(36)); BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(128), 1); "A2B0"u8.CopyTo(bytes.AsSpan(132)); BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(136), TagOffset); BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(140), (uint)tag.Count); tag.CopyTo(bytes, TagOffset); return bytes; } private static byte[] BigEndian(uint value) { var bytes = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(bytes, value); return bytes; } // process-wide and precise, because the async path may resume on another thread; the callers // are [NonParallelizable] so no other test's allocations land in the count private static long AllocatedDuring(Action action) { long before = GC.GetTotalAllocatedBytes(precise: true); action(); return GC.GetTotalAllocatedBytes(precise: true) - before; } private static async Task AllocatedDuringAsync(Func action) { long before = GC.GetTotalAllocatedBytes(precise: true); await action(); return GC.GetTotalAllocatedBytes(precise: true) - before; } internal static async Task RealTiff(int width, int height) { using var image = new Image(width, height); var stream = new MemoryStream(); await image.SaveAsync(stream, new TiffEncoder()); stream.Position = 0; return stream; } /// /// A real TIFF whose ImageWidth/ImageLength IFD entries are rewritten to declare /// x , so Identify reports a size the /// pixel data does not back. /// private static async Task TiffDeclaring(int width, int height) { await using MemoryStream source = await RealTiff(8, 8); byte[] bytes = source.ToArray(); // TiffEncoder writes little-endian ("II"); the first IFD offset is at byte 4 bytes[0].ShouldBe((byte)'I'); var ifd = (int)BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(4)); int count = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(ifd)); var patched = 0; for (var i = 0; i < count; i++) { int entry = ifd + 2 + i * 12; ushort tag = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(entry)); ushort type = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(entry + 2)); if (tag is not (256 or 257)) { continue; } int value = tag == 256 ? width : height; Span slot = bytes.AsSpan(entry + 8, 4); slot.Clear(); if (type == 3) { BinaryPrimitives.WriteUInt16LittleEndian(slot, checked((ushort)value)); } else { BinaryPrimitives.WriteUInt32LittleEndian(slot, (uint)value); } patched++; } patched.ShouldBe(2); return new MemoryStream(bytes); } }