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
146 lines
5.8 KiB
C#
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);
|
|
}
|
|
}
|