Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 10s
Review verdict / Set review-verdict status (pull_request_target) Successful in 16s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 12m42s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m56s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
review-verdict/h10 Review-verdict: MERGEABLE @ 71d660f (base: main)
Round 4 review found two gaps in the gwg2 runtime pin. The carrier list (png, jpeg, webp) was hand-written and left out BMP, whose 3.1.12 decoder also constructs an IccProfile (BmpDecoderCore::ReadInfoHeader) and which remote-URL and on-disk graphics can reach. And the record said every hostile-ICC case asserts the profile is still attached, while the WatermarkElement case asserts only allocation (its images are private). - IccCarriers is now bmp, jpeg, png, webp, with a BmpEncoder arm. - Icc_Carriers_Are_Every_Configured_Format_That_Round_Trips_A_Profile round-trips the hostile profile through each format of ImageSharpDecoding.Configuration via its default encoder and requires the formats that come back carrying a profile to equal IccCarriers. Witnessed red by dropping "bmp" from the list. - A Cecil scan of the 3.1.12 package (2026-10-28) lists the decoders that construct an IccProfile as BMP, JPEG, PNG, WebP and TIFF, so the round trip's blind spot (a decoder reading a profile its encoder never writes) is empty today; the record says so, dated. - The record and the element test's comment now say only the validator cases assert attachment; the element case shares their fixture. Measured: product path 38-94 KiB, element 368-834 KiB, Entries control about 129,545 KiB for all four carriers. Decisions-Edit: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
326 lines
14 KiB
C#
326 lines
14 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.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;
|
|
|
|
/// <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 = ["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<string>();
|
|
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<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 Task<MemoryStream> 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<MemoryStream> WithHostileIcc(IImageEncoder encoder)
|
|
{
|
|
using var image = new Image<Rgba32>(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<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);
|
|
}
|
|
}
|