Files
ersatztv/ErsatzTV.Infrastructure.Tests/Images/ImageSharpDecodingTests.cs
T
timothyandClaude Opus 5.5 e96e47a9d8 docs(929): record why ImageSharp stays on 3.1.12 and what bounds each advisory
New record security.imagesharp-3x-tiff-decoder-disabled: the license-key
build gate in every patched release (measured on 3.2.0 and 4.1.3 Release
builds), the per-advisory reachability table, the masked Load-pass clause
(measured: reverting it alone leaves the suite green), that dotnet list
--vulnerable ignores NuGetAuditSuppress (measured), and the revisit trigger.
ci-cd.md Dependency management records the Pkcs direct pin, the rejected
central transitive pin (NU1109), and that the weekly scan is red by design
on ImageSharp until then. The #511 record's MaxFrames measurement is dated
to 3.1.12 and no longer lists TIFF as in play.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
2026-10-30 00:03:25 +01:00

146 lines
5.8 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.Tiff;
using SixLabors.ImageSharp.PixelFormats;
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
// first, so the Load pass's own configuration is MASKED here: reverting only that one leaves
// this fixture green. The header test below is what pins the Identify pass.
[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));
}
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);
}
}