Round-3 review: the record said every bound is enforced over compiled code, but half of the gwg2 (ICC CLUT) bound is ImageSharp 3.1.12's own runtime behaviour, which the IL scan cannot see. A 208-byte ICC profile declaring an mft2 CLUT (3 in, 15 out, 255 grid points), carried in PNG, JPEG and WebP, now goes through RemoteImageValidator.DecodeAndValidate + CloneFrame/Resize/CloneAs and through a real remote WatermarkElement. Each case asserts the profile is still attached and fails above 16 MiB allocated. The positive control reads Entries on the same profile and must allocate over 64 MiB. Measured 2026-10-28 on 3.1.12: the product paths allocate 38-94 KiB and the element 322-390 KiB; Entries allocates about 129,550 KiB. Mutation witnesses: a try/catch read of IccProfile.Entries injected after the validator's post-decode check reddens all 6 product-path cases on the allocation assertion. The same read after ImageElementBase's per-frame Resize reddens the 3 element cases. Both were restored from git. The record's gwg2 row and 'What holds the bounds' now name the two halves and which test holds each. docs/testing.md names the ICC cases. Decisions-Edit: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
298 lines
12 KiB
C#
298 lines
12 KiB
C#
using System.Buffers.Binary;
|
|
using ErsatzTV.Infrastructure.Images;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.Formats;
|
|
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;
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
/// </summary>
|
|
[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<UnknownImageFormatException>(
|
|
() => 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<UnknownImageFormatException>(
|
|
() => 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 = ["png", "jpeg", "webp"];
|
|
|
|
// 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<Rgba32> rgba = frame.CloneAs<Rgba32>();
|
|
rgba.Metadata.IccProfile.ShouldNotBeNull();
|
|
});
|
|
|
|
TestContext.Out.WriteLine($"{format}: decode, clone and resize allocated {allocated / 1024} KiB");
|
|
allocated.ShouldBeLessThan(ProductPathCeiling);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A 16x16 image in <paramref name="format" /> carrying an ICC profile whose A2B0 tag is an
|
|
/// <c>mft2</c> (lut16) with 3 inputs, 15 outputs and 255 grid points, truncated before the
|
|
/// CLUT: a 208-byte profile.
|
|
/// </summary>
|
|
internal static async Task<MemoryStream> WithHostileIcc(string format)
|
|
{
|
|
using var image = new Image<Rgba32>(16, 16);
|
|
image.Metadata.IccProfile = new IccProfile(HostileIccBytes());
|
|
|
|
IImageEncoder encoder = format switch
|
|
{
|
|
"png" => new PngEncoder(),
|
|
"jpeg" => new JpegEncoder(),
|
|
"webp" => new WebpEncoder { FileFormat = WebpFileFormatType.Lossless },
|
|
_ => throw new ArgumentOutOfRangeException(nameof(format), format, null)
|
|
};
|
|
|
|
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<byte>();
|
|
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<long> AllocatedDuringAsync(Func<Task> action)
|
|
{
|
|
long before = GC.GetTotalAllocatedBytes(precise: true);
|
|
await action();
|
|
return GC.GetTotalAllocatedBytes(precise: true) - before;
|
|
}
|
|
|
|
internal static async Task<MemoryStream> RealTiff(int width, int height)
|
|
{
|
|
using var image = new Image<Rgba32>(width, height);
|
|
var stream = new MemoryStream();
|
|
await image.SaveAsync(stream, new TiffEncoder());
|
|
stream.Position = 0;
|
|
return stream;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A real TIFF whose ImageWidth/ImageLength IFD entries are rewritten to declare
|
|
/// <paramref name="width" /> x <paramref name="height" />, so Identify reports a size the
|
|
/// pixel data does not back.
|
|
/// </summary>
|
|
private static async Task<MemoryStream> 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<byte> 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);
|
|
}
|
|
}
|