Compare commits
7
Commits
main
...
71d660f213
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71d660f213 | ||
|
|
bed50e2b77 | ||
|
|
59749babd9 | ||
|
|
f30a54172b | ||
|
|
52c383e832 | ||
|
|
e96e47a9d8 | ||
|
|
9eed552876 |
@@ -58,6 +58,7 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageVersion Include="Mono.Cecil" Version="0.11.6" />
|
||||
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15" />
|
||||
<PackageVersion Include="NCalcSync" Version="6.3.2" />
|
||||
<PackageVersion Include="NetArchTest.eNhancedEdition" Version="1.4.5" />
|
||||
@@ -87,6 +88,9 @@
|
||||
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||
<!-- HELD at 3.1.12: every patched release (3.2.0, 4.1.2+) fails a Release build without a Six Labors
|
||||
license key. Do not bump until one is provisioned; the advisory bounds and the revisit steps are in
|
||||
security.imagesharp-3x-tiff-decoder-disabled, and renovate.json holds Renovate's update PRs (#929). -->
|
||||
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageVersion Include="SkiaSharp" Version="3.119.1" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" />
|
||||
@@ -95,6 +99,12 @@
|
||||
bundled SQLite, GHSA-2m69-gcr7-jv3q). The 3.x line ships the patched native
|
||||
(lib.e_sqlite3 3.50.3); core 3.0.4 satisfies Microsoft.Data.Sqlite's `>= 2.1.10`. (#8) -->
|
||||
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
|
||||
<!-- Direct pin to override the transitive 9.0.4 that EFCore.BulkExtensions' Oracle and SqlServer
|
||||
providers pull in (via Oracle.ManagedDataAccess.Core / Microsoft.Data.SqlClient):
|
||||
GHSA-846f-f764-76mx (High) and GHSA-2fmv-rv2c-59x7 (Moderate), both fixed in 9.0.21.
|
||||
Referenced directly in ErsatzTV.Infrastructure.csproj, the only project that references
|
||||
EFCore.BulkExtensions, so the override flows to every project above it (#929). -->
|
||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="9.0.21" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.2" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="Testably.Abstractions" Version="10.0.0" />
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<!-- IL reads for ImageSharpSurfaceTests (#929); NetArchTest already depends on it -->
|
||||
<PackageReference Include="Mono.Cecil" />
|
||||
<PackageReference Include="NetArchTest.eNhancedEdition" />
|
||||
<PackageReference Include="NUnit" />
|
||||
<PackageReference Include="NUnit3TestAdapter" />
|
||||
@@ -33,6 +35,8 @@
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure.Sqlite\ErsatzTV.Infrastructure.Sqlite.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure.MySql\ErsatzTV.Infrastructure.MySql.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Scanner\ErsatzTV.Scanner.csproj" />
|
||||
<!-- the host: ImageSharpSurfaceTests scans every assembly that can reach ImageSharp (#929) -->
|
||||
<ProjectReference Include="..\ErsatzTV\ErsatzTV.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
using System.Xml.Linq;
|
||||
using Mono.Cecil;
|
||||
using Mono.Cecil.Cil;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Architecture.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ersatztv#929, <c>security.imagesharp-3x-tiff-decoder-disabled</c>: SixLabors.ImageSharp is held at
|
||||
/// 3.1.12, and what keeps its advisories unreachable is how ErsatzTV CALLS it, not the package. Every
|
||||
/// decode must go through <c>ImageSharpDecoding</c>'s TIFF-blind configuration, and nothing may touch
|
||||
/// the TIFF codec, ICC profile entries or the histogram-equalization processors.
|
||||
/// <para>
|
||||
/// These rules read the IL of every production assembly that can reach ImageSharp, so a NEW call
|
||||
/// site fails here instead of quietly reopening an advisory. The population is derived from the
|
||||
/// solution and the csproj graph, not listed: a project that gains an ImageSharp reference joins
|
||||
/// it, and this fixture fails until the project is referenced here and therefore scanned.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ImageSharpSurfaceTests
|
||||
{
|
||||
private const string ImageSharpPackage = "SixLabors.ImageSharp";
|
||||
private const string ImageSharpNamespace = "SixLabors.ImageSharp.";
|
||||
private const string DecodingType = "ErsatzTV.Infrastructure.Images.ImageSharpDecoding";
|
||||
private const string DecoderOptionsType = "SixLabors.ImageSharp.Formats.DecoderOptions";
|
||||
private const string ConfigurationType = "SixLabors.ImageSharp.Configuration";
|
||||
private const string FormatManagerType = "SixLabors.ImageSharp.Formats.ImageFormatManager";
|
||||
|
||||
// ImageSharp's format-decoding entry points: Image.Load*/Identify*/DetectFormat* and IImageDecoder's
|
||||
// Decode*/Identify*. LoadPixelData wraps raw pixels and runs no format decoder.
|
||||
private static readonly string[] DecodeEntryPrefixes = ["Load", "Identify", "DetectFormat", "Decode"];
|
||||
private static readonly string[] NonDecodingEntries = ["LoadPixelData"];
|
||||
|
||||
// Where the 3.1.12 advisories live that no decoder configuration can bound: the TIFF codecs (the
|
||||
// encoders carry GHSA-jjfr / GHSA-j9gm), ICC profile parsing (GHSA-gwg2) and histogram equalization
|
||||
// (GHSA-j3p4). The whole Normalization namespace is banned, not just the one processor, because its
|
||||
// processors share the histogram code.
|
||||
private static readonly string[] ForbiddenTypePrefixes =
|
||||
[
|
||||
"SixLabors.ImageSharp.Formats.Tiff.",
|
||||
"SixLabors.ImageSharp.Metadata.Profiles.Icc.",
|
||||
"SixLabors.ImageSharp.Processing.Processors.Normalization.",
|
||||
"SixLabors.ImageSharp.Processing.HistogramEqualizationExtensions"
|
||||
];
|
||||
|
||||
// Extension methods such as ImageExtensions.SaveAsTiff carry no TIFF type in their signature.
|
||||
private static readonly string[] ForbiddenImageSharpMemberFragments = ["Tiff", "HistogramEqualization"];
|
||||
|
||||
private static readonly Lazy<IReadOnlyList<string>> Population = new(ImageSharpReachingAssemblies);
|
||||
|
||||
private static readonly Lazy<IReadOnlyList<Site>> Sites = new(() => Scan(Population.Value));
|
||||
|
||||
[Test]
|
||||
public void Every_production_assembly_that_can_reach_ImageSharp_is_scanned()
|
||||
{
|
||||
IReadOnlyList<string> population = Population.Value;
|
||||
|
||||
// sanity: the csproj walk found the package reference and followed project references upward
|
||||
population.ShouldContain("ErsatzTV.Infrastructure");
|
||||
population.ShouldContain("ErsatzTV");
|
||||
|
||||
List<string> missing = population
|
||||
.Where(name => !File.Exists(Path.Combine(AppContext.BaseDirectory, name + ".dll")))
|
||||
.ToList();
|
||||
|
||||
missing.ShouldBeEmpty(
|
||||
$"{string.Join(", ", missing)} can reach {ImageSharpPackage} but is not in this test's output, so "
|
||||
+ "its ImageSharp calls are not checked. Add a ProjectReference to ErsatzTV.Architecture.Tests.csproj.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Every_ImageSharp_decode_passes_DecoderOptions()
|
||||
{
|
||||
List<Site> decodes = Sites.Value
|
||||
.Where(s => s.Callee is not null && IsDecodeEntry(s.Callee))
|
||||
.ToList();
|
||||
|
||||
// sanity: the scan reaches the decodes we know exist, including the ones inside async state
|
||||
// machines (nested compiler-generated types), so an empty result cannot pass for a clean one
|
||||
decodes.ShouldContain(s => s.CallerType.StartsWith("ErsatzTV.Infrastructure.Images.RemoteImageValidator", StringComparison.Ordinal));
|
||||
decodes.ShouldContain(s => s.CallerType.StartsWith("ErsatzTV.Infrastructure.Streaming.Graphics.ImageElementBase", StringComparison.Ordinal));
|
||||
|
||||
List<string> violations = decodes
|
||||
.Where(s => !s.Callee!.Parameters.Any(p => TypeName(p.ParameterType) == DecoderOptionsType))
|
||||
.Select(s => s.Describe())
|
||||
.ToList();
|
||||
|
||||
violations.ShouldBeEmpty(
|
||||
"ImageSharp decode(s) without DecoderOptions, which fall back to Configuration.Default and so "
|
||||
+ "can detect and decode TIFF. Pass ImageSharpDecoding.Options(...):\n "
|
||||
+ string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Only_ImageSharpDecoding_constructs_DecoderOptions()
|
||||
{
|
||||
List<Site> constructions = Sites.Value
|
||||
.Where(s => s.OpCode == OpCodes.Newobj
|
||||
&& s.Callee is not null
|
||||
&& TypeName(s.Callee.DeclaringType) == DecoderOptionsType)
|
||||
.ToList();
|
||||
|
||||
// sanity: the one legitimate construction is visible to the scan
|
||||
constructions.ShouldContain(s => IsInDecodingType(s.CallerType));
|
||||
|
||||
List<string> violations = constructions
|
||||
.Where(s => !IsInDecodingType(s.CallerType))
|
||||
.Select(s => s.Describe())
|
||||
.ToList();
|
||||
|
||||
violations.ShouldBeEmpty(
|
||||
"DecoderOptions constructed outside ImageSharpDecoding. Its Configuration defaults to "
|
||||
+ "Configuration.Default, which decodes TIFF. Use ImageSharpDecoding.Options(...):\n "
|
||||
+ string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
// Passing ImageSharpDecoding's DecoderOptions is not enough on its own: the TIFF-blind configuration
|
||||
// must also be the one that picks the decoder. Outside ImageSharpDecoding nothing may assign a
|
||||
// DecoderOptions.Configuration, read Configuration.Default (which registers TIFF), or touch an
|
||||
// ImageFormatManager, the registry a decoder can be fetched from directly — a decoder fetched that
|
||||
// way decodes its own format whatever the options' configuration says.
|
||||
[Test]
|
||||
public void Only_ImageSharpDecoding_chooses_the_decode_Configuration()
|
||||
{
|
||||
List<Site> sites = Sites.Value.ToList();
|
||||
|
||||
// sanity: the one legitimate Configuration assignment is visible to the scan
|
||||
sites.ShouldContain(s => IsInDecodingType(s.CallerType) && IsCall(s, DecoderOptionsType, "set_Configuration"));
|
||||
|
||||
List<string> violations = sites
|
||||
.Where(s => !IsInDecodingType(s.CallerType))
|
||||
.Where(s => IsCall(s, DecoderOptionsType, "set_Configuration")
|
||||
|| IsCall(s, ConfigurationType, "get_Default")
|
||||
|| s.ReferencedTypes.Contains(FormatManagerType))
|
||||
.Select(s => s.Describe())
|
||||
.ToList();
|
||||
|
||||
violations.ShouldBeEmpty(
|
||||
"Code outside ImageSharpDecoding chooses an ImageSharp decode configuration or decoder. "
|
||||
+ "Configuration.Default and its ImageFormatManager register TIFF. Use ImageSharpDecoding.Options(...):\n "
|
||||
+ string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void No_code_reaches_the_ImageSharp_paths_the_3x_advisories_live_in()
|
||||
{
|
||||
List<Site> sites = Sites.Value.ToList();
|
||||
|
||||
// sanity: the type walk sees an extension method called from inside a lambda (Mutate's Resize),
|
||||
// which is the same shape a HistogramEqualization() call would have
|
||||
sites.ShouldContain(s => s.ReferencedTypes.Contains("SixLabors.ImageSharp.Processing.ResizeExtensions"));
|
||||
|
||||
List<string> violations = sites
|
||||
.Where(s => s.ReferencedTypes.Any(IsForbiddenType)
|
||||
|| s.Callee is not null
|
||||
&& TypeName(s.Callee.DeclaringType).StartsWith(ImageSharpNamespace, StringComparison.Ordinal)
|
||||
&& ForbiddenImageSharpMemberFragments.Any(f => s.Callee.Name.Contains(f, StringComparison.Ordinal)))
|
||||
.Select(s => s.Describe())
|
||||
.ToList();
|
||||
|
||||
violations.ShouldBeEmpty(
|
||||
"Code reaches an ImageSharp 3.1.12 path with an unpatched advisory (TIFF codec, ICC profile "
|
||||
+ "entries, histogram equalization). See security.imagesharp-3x-tiff-decoder-disabled:\n "
|
||||
+ string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
private static bool IsDecodeEntry(MethodReference callee) =>
|
||||
TypeName(callee.DeclaringType).StartsWith(ImageSharpNamespace, StringComparison.Ordinal)
|
||||
&& DecodeEntryPrefixes.Any(p => callee.Name.StartsWith(p, StringComparison.Ordinal))
|
||||
&& !NonDecodingEntries.Contains(callee.Name);
|
||||
|
||||
private static bool IsCall(Site site, string declaringType, string methodName) =>
|
||||
site.Callee is not null
|
||||
&& site.Callee.Name == methodName
|
||||
&& TypeName(site.Callee.DeclaringType) == declaringType;
|
||||
|
||||
private static bool IsInDecodingType(string typeName) =>
|
||||
typeName == DecodingType || typeName.StartsWith(DecodingType + "/", StringComparison.Ordinal);
|
||||
|
||||
private static bool IsForbiddenType(string typeName) =>
|
||||
ForbiddenTypePrefixes.Any(p => typeName.StartsWith(p, StringComparison.Ordinal));
|
||||
|
||||
private static List<Site> Scan(IReadOnlyList<string> assemblyNames)
|
||||
{
|
||||
var sites = new List<Site>();
|
||||
foreach (string name in assemblyNames)
|
||||
{
|
||||
string path = Path.Combine(AppContext.BaseDirectory, name + ".dll");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
// reported by Every_production_assembly_that_can_reach_ImageSharp_is_scanned
|
||||
continue;
|
||||
}
|
||||
|
||||
// read fully into memory and kept alive: Site holds MethodReferences whose parameters
|
||||
// are read lazily, after this loop
|
||||
AssemblyDefinition assembly = AssemblyDefinition.ReadAssembly(
|
||||
path,
|
||||
new ReaderParameters { InMemory = true, ReadingMode = ReadingMode.Immediate });
|
||||
foreach (TypeDefinition type in assembly.MainModule.GetTypes())
|
||||
{
|
||||
var signature = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (type.BaseType is not null)
|
||||
{
|
||||
AddTypeNames(type.BaseType, signature);
|
||||
}
|
||||
|
||||
foreach (InterfaceImplementation implemented in type.Interfaces)
|
||||
{
|
||||
AddTypeNames(implemented.InterfaceType, signature);
|
||||
}
|
||||
|
||||
foreach (FieldDefinition field in type.Fields)
|
||||
{
|
||||
AddTypeNames(field.FieldType, signature);
|
||||
}
|
||||
|
||||
if (signature.Count > 0)
|
||||
{
|
||||
sites.Add(new Site(name, type.FullName, "(type signature)", OpCodes.Nop, null, signature));
|
||||
}
|
||||
|
||||
foreach (MethodDefinition method in type.Methods)
|
||||
{
|
||||
var methodTypes = new HashSet<string>(StringComparer.Ordinal);
|
||||
AddTypeNames(method.ReturnType, methodTypes);
|
||||
foreach (ParameterDefinition parameter in method.Parameters)
|
||||
{
|
||||
AddTypeNames(parameter.ParameterType, methodTypes);
|
||||
}
|
||||
|
||||
if (!method.HasBody)
|
||||
{
|
||||
sites.Add(new Site(name, type.FullName, method.Name, OpCodes.Nop, null, methodTypes));
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (VariableDefinition variable in method.Body.Variables)
|
||||
{
|
||||
AddTypeNames(variable.VariableType, methodTypes);
|
||||
}
|
||||
|
||||
sites.Add(new Site(name, type.FullName, method.Name, OpCodes.Nop, null, methodTypes));
|
||||
|
||||
foreach (Instruction instruction in method.Body.Instructions)
|
||||
{
|
||||
var referenced = new HashSet<string>(StringComparer.Ordinal);
|
||||
MethodReference? callee = null;
|
||||
switch (instruction.Operand)
|
||||
{
|
||||
case MethodReference methodReference:
|
||||
callee = methodReference;
|
||||
AddMethodTypeNames(methodReference, referenced);
|
||||
break;
|
||||
case FieldReference fieldReference:
|
||||
AddTypeNames(fieldReference.DeclaringType, referenced);
|
||||
AddTypeNames(fieldReference.FieldType, referenced);
|
||||
break;
|
||||
case TypeReference typeReference:
|
||||
AddTypeNames(typeReference, referenced);
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
sites.Add(new Site(name, type.FullName, method.Name, instruction.OpCode, callee, referenced));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sites;
|
||||
}
|
||||
|
||||
private static void AddMethodTypeNames(MethodReference method, HashSet<string> names)
|
||||
{
|
||||
AddTypeNames(method.DeclaringType, names);
|
||||
AddTypeNames(method.ReturnType, names);
|
||||
foreach (ParameterDefinition parameter in method.Parameters)
|
||||
{
|
||||
AddTypeNames(parameter.ParameterType, names);
|
||||
}
|
||||
|
||||
if (method is GenericInstanceMethod generic)
|
||||
{
|
||||
foreach (TypeReference argument in generic.GenericArguments)
|
||||
{
|
||||
AddTypeNames(argument, names);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddTypeNames(TypeReference type, HashSet<string> names)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case GenericParameter:
|
||||
return;
|
||||
case GenericInstanceType generic:
|
||||
AddTypeNames(generic.ElementType, names);
|
||||
foreach (TypeReference argument in generic.GenericArguments)
|
||||
{
|
||||
AddTypeNames(argument, names);
|
||||
}
|
||||
|
||||
return;
|
||||
case TypeSpecification specification:
|
||||
// arrays, by-ref, pointers, modifiers
|
||||
AddTypeNames(specification.ElementType, names);
|
||||
return;
|
||||
default:
|
||||
names.Add(TypeName(type));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace-qualified for nested types too: Cecil's FullName for a nested type is "Outer/Inner" and
|
||||
// carries the outer type's namespace, so prefix checks on the namespace still apply.
|
||||
private static string TypeName(TypeReference type) => type.GetElementType().FullName;
|
||||
|
||||
/// <summary>
|
||||
/// Non-test projects in <c>ErsatzTV.sln</c> whose ProjectReference closure contains a direct
|
||||
/// <c>SixLabors.ImageSharp</c> PackageReference, as assembly names.
|
||||
/// </summary>
|
||||
private static List<string> ImageSharpReachingAssemblies()
|
||||
{
|
||||
string root = RepoRoot();
|
||||
string solution = File.ReadAllText(Path.Combine(root, "ErsatzTV.sln"));
|
||||
|
||||
List<string> projects = System.Text.RegularExpressions.Regex
|
||||
.Matches(solution, "^Project\\(\"[^\"]+\"\\) = \"[^\"]+\", \"([^\"]+\\.csproj)\"", System.Text.RegularExpressions.RegexOptions.Multiline)
|
||||
.Select(m => Path.GetFullPath(Path.Combine(root, m.Groups[1].Value.Replace('\\', Path.DirectorySeparatorChar))))
|
||||
.ToList();
|
||||
|
||||
// sanity: the solution parse found the projects at all
|
||||
projects.Count.ShouldBeGreaterThan(5, "could not parse the project list out of ErsatzTV.sln");
|
||||
|
||||
var memo = new Dictionary<string, bool>(StringComparer.Ordinal);
|
||||
return projects
|
||||
.Where(p => !Path.GetFileNameWithoutExtension(p).EndsWith(".Tests", StringComparison.Ordinal))
|
||||
.Where(p => ReachesImageSharp(p, memo, []))
|
||||
.Select(AssemblyName)
|
||||
.OrderBy(n => n, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool ReachesImageSharp(string csproj, Dictionary<string, bool> memo, HashSet<string> visiting)
|
||||
{
|
||||
if (memo.TryGetValue(csproj, out bool known))
|
||||
{
|
||||
return known;
|
||||
}
|
||||
|
||||
if (!visiting.Add(csproj))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
XDocument document = XDocument.Load(csproj);
|
||||
bool direct = document.Descendants("PackageReference")
|
||||
.Any(e => string.Equals((string?)e.Attribute("Include"), ImageSharpPackage, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
string directory = Path.GetDirectoryName(csproj)!;
|
||||
bool result = direct || document.Descendants("ProjectReference")
|
||||
.Select(e => (string?)e.Attribute("Include"))
|
||||
.OfType<string>()
|
||||
.Select(include => Path.GetFullPath(Path.Combine(directory, include.Replace('\\', Path.DirectorySeparatorChar))))
|
||||
.Any(reference => ReachesImageSharp(reference, memo, visiting));
|
||||
|
||||
memo[csproj] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string AssemblyName(string csproj)
|
||||
{
|
||||
string? declared = XDocument.Load(csproj).Descendants("AssemblyName").Select(e => e.Value.Trim()).FirstOrDefault();
|
||||
return string.IsNullOrEmpty(declared) ? Path.GetFileNameWithoutExtension(csproj) : declared;
|
||||
}
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "ErsatzTV.sln")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
directory.ShouldNotBeNull("could not locate the repository root (no ErsatzTV.sln above the test binary)");
|
||||
return directory!.FullName;
|
||||
}
|
||||
|
||||
private sealed record Site(
|
||||
string Assembly,
|
||||
string CallerType,
|
||||
string CallerMethod,
|
||||
OpCode OpCode,
|
||||
MethodReference? Callee,
|
||||
IReadOnlySet<string> ReferencedTypes)
|
||||
{
|
||||
public string Describe() =>
|
||||
$"{Assembly}: {CallerType}::{CallerMethod}"
|
||||
+ (Callee is null ? $" [{string.Join(", ", ReferencedTypes.Where(IsForbiddenType))}]" : $" -> {Callee.FullName}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using ErsatzTV.Infrastructure.Tests.Images;
|
||||
using LanguageExt;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Png;
|
||||
@@ -129,6 +130,71 @@ public class WatermarkElementRemoteImageTests
|
||||
element.IsFinished.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- TIFF is not decoded on either path (ersatztv#929, security.imagesharp-3x-tiff-decoder-disabled) ---
|
||||
//
|
||||
// Each TIFF case has a PNG twin that differs only in the encoder, so a disabled element is the
|
||||
// decoder configuration's doing, not a broken fixture or a path that never loads.
|
||||
|
||||
[TestCase(true, ExpectedResult = true)]
|
||||
[TestCase(false, ExpectedResult = false)]
|
||||
public async Task<bool> Should_Disable_The_Watermark_For_A_Remote_Tiff_Only(bool tiff)
|
||||
{
|
||||
MemoryStream source = tiff ? await ImageSharpDecodingTests.RealTiff(64, 64) : Apng(64, 64, 1);
|
||||
var element = new WatermarkElement(RemoteWatermarkOptions(), FetcherReturning(source), Substitute.For<ILogger>());
|
||||
|
||||
await element.InitializeAsync(Context(), CancellationToken.None);
|
||||
|
||||
return element.IsFinished;
|
||||
}
|
||||
|
||||
[TestCase(true, ExpectedResult = true)]
|
||||
[TestCase(false, ExpectedResult = false)]
|
||||
public async Task<bool> Should_Disable_The_Watermark_For_A_Local_Tiff_Only(bool tiff)
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"etv-929-{Guid.NewGuid():N}.{(tiff ? "tif" : "png")}");
|
||||
try
|
||||
{
|
||||
await using (MemoryStream source = tiff ? await ImageSharpDecodingTests.RealTiff(64, 64) : Apng(64, 64, 1))
|
||||
{
|
||||
await File.WriteAllBytesAsync(path, source.ToArray());
|
||||
}
|
||||
|
||||
var options = new WatermarkOptions(Watermark(), path, Option<int>.None);
|
||||
var element = new WatermarkElement(options, Substitute.For<IRemoteImageFetcher>(), Substitute.For<ILogger>());
|
||||
|
||||
await element.InitializeAsync(Context(), CancellationToken.None);
|
||||
|
||||
return element.IsFinished;
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
// GHSA-gwg2-r3hj-4w44 over the element itself rather than a replica of its calls: decode,
|
||||
// CloneFrame, Resize and CloneAs carry a hostile ICC profile through without sizing its CLUT.
|
||||
// The element's images are private, so this case cannot assert the profile is still attached; it
|
||||
// shares its fixture with ImageSharpDecodingTests, whose
|
||||
// Decode_Clone_And_Resize_Do_Not_Parse_A_Hostile_Icc_Profile asserts attachment after each step and
|
||||
// whose Hostile_Icc_Fixture_Allocates_When_Its_Entries_Are_Parsed proves the fixture hostile.
|
||||
// (ersatztv#929, security.imagesharp-3x-tiff-decoder-disabled)
|
||||
[TestCaseSource(typeof(ImageSharpDecodingTests), nameof(ImageSharpDecodingTests.IccCarriers))]
|
||||
[NonParallelizable]
|
||||
public async Task Should_Render_A_Remote_Watermark_Without_Parsing_Its_Icc_Profile(string format)
|
||||
{
|
||||
MemoryStream source = await ImageSharpDecodingTests.WithHostileIcc(format);
|
||||
var element = new WatermarkElement(RemoteWatermarkOptions(), FetcherReturning(source), Substitute.For<ILogger>());
|
||||
|
||||
long before = GC.GetTotalAllocatedBytes(precise: true);
|
||||
await element.InitializeAsync(Context(), CancellationToken.None);
|
||||
long allocated = GC.GetTotalAllocatedBytes(precise: true) - before;
|
||||
|
||||
TestContext.Out.WriteLine($"{format}: watermark initialization allocated {allocated / 1024} KiB");
|
||||
element.IsFinished.ShouldBeFalse();
|
||||
allocated.ShouldBeLessThan(ImageSharpDecodingTests.ProductPathCeiling);
|
||||
}
|
||||
|
||||
private static IRemoteImageFetcher FetcherReturning(Stream stream)
|
||||
{
|
||||
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
<PackageReference Include="Scriban.Signed" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" />
|
||||
<PackageReference Include="SkiaSharp" />
|
||||
<!-- Direct ref forces the patched System.Security.Cryptography.Pkcs over the vulnerable 9.0.4
|
||||
that EFCore.BulkExtensions' Oracle/SqlServer providers pull transitively (#929).
|
||||
Version lives in Directory.Packages.props (CPM). -->
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" />
|
||||
<PackageReference Include="TagLibSharp" />
|
||||
<PackageReference Include="TimeZoneConverter" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats;
|
||||
using SixLabors.ImageSharp.Formats.Bmp;
|
||||
using SixLabors.ImageSharp.Formats.Gif;
|
||||
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||
using SixLabors.ImageSharp.Formats.Pbm;
|
||||
using SixLabors.ImageSharp.Formats.Png;
|
||||
using SixLabors.ImageSharp.Formats.Qoi;
|
||||
using SixLabors.ImageSharp.Formats.Tga;
|
||||
using SixLabors.ImageSharp.Formats.Webp;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Images;
|
||||
|
||||
/// <summary>
|
||||
/// The ImageSharp <see cref="Configuration" /> every ImageSharp decode in ErsatzTV goes through.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It registers every format <see cref="Configuration.Default" /> registers EXCEPT TIFF, so a TIFF is
|
||||
/// rejected with <see cref="UnknownImageFormatException" /> before any TIFF decoder code runs.
|
||||
/// ImageSharp 3.1.12's TIFF decoder carries GHSA-v76p-62qx-wwq2 (heap out-of-bounds write from a tiled
|
||||
/// fax TIFF) and GHSA-wmxv-xphr-5c9g (non-progressing BigTIFF IFD loop). TIFF is outside the artwork
|
||||
/// allow-list (<c>ImageContentTypes.Accepted</c>), so what this removes is only a remote-URL or on-disk
|
||||
/// TIFF graphic. It is a fresh instance rather than a <c>Configuration.Default.Clone()</c> because
|
||||
/// Clone is shallow: removing a detector from the clone would remove it from Default too.
|
||||
/// <c>ImageSharpDecodingTests</c> pins the module list against <c>Configuration.Default</c>, so a
|
||||
/// package bump that adds a format fails a test instead of silently dropping it, and
|
||||
/// <c>ImageSharpSurfaceTests</c> (ErsatzTV.Architecture.Tests) fails any production decode that does not
|
||||
/// take these options.
|
||||
/// Rationale and revisit trigger: <c>security.imagesharp-3x-tiff-decoder-disabled</c>. (ersatztv#929)
|
||||
/// </remarks>
|
||||
public static class ImageSharpDecoding
|
||||
{
|
||||
public static Configuration Configuration { get; } = new(
|
||||
new PngConfigurationModule(),
|
||||
new JpegConfigurationModule(),
|
||||
new GifConfigurationModule(),
|
||||
new BmpConfigurationModule(),
|
||||
new PbmConfigurationModule(),
|
||||
new TgaConfigurationModule(),
|
||||
new WebpConfigurationModule(),
|
||||
new QoiConfigurationModule());
|
||||
|
||||
public static DecoderOptions Options() => new() { Configuration = Configuration };
|
||||
|
||||
public static DecoderOptions Options(uint maxFrames) =>
|
||||
new() { Configuration = Configuration, MaxFrames = maxFrames };
|
||||
}
|
||||
@@ -41,8 +41,11 @@ public class RemoteImageValidator : IRemoteImageValidator
|
||||
// PngEncoder wrote, which Image.Load then reads back perfectly (measured: 13 of 16 shapes).
|
||||
// Without this, adding the header pre-pass would silently disable every animated-PNG logo
|
||||
// that worked before this change. Only Width/Height are read below, and those stay correct.
|
||||
// Both passes use ImageSharpDecoding's configuration, which cannot detect TIFF (ersatztv#929).
|
||||
// This pass is the one that rejects a TIFF; the decode pass takes the same configuration so
|
||||
// the two always agree on which formats exist (held by ImageSharpSurfaceTests).
|
||||
ImageInfo info = await Image.IdentifyAsync(
|
||||
new DecoderOptions { MaxFrames = 1 },
|
||||
ImageSharpDecoding.Options(maxFrames: 1),
|
||||
stream,
|
||||
cancellationToken);
|
||||
|
||||
@@ -58,12 +61,12 @@ public class RemoteImageValidator : IRemoteImageValidator
|
||||
stream.Position = 0;
|
||||
|
||||
// MaxFrames is enforced BY THE DECODER, so it holds whatever the header claimed — measured
|
||||
// as honored by every animated decoder here (APNG, GIF, WebP, TIFF). Ask for two more than
|
||||
// as honored by every animated decoder here (APNG, GIF, WebP). Ask for two more than
|
||||
// the budget allows so that an animation exactly AT the limit still decodes in full, while
|
||||
// anything over it is present in the decoded image for the post-decode check below to
|
||||
// reject. Slop is at most two frames: MaxFrames = N yields N frames for GIF/WebP/TIFF but
|
||||
// reject. Slop is at most two frames: MaxFrames = N yields N frames for GIF/WebP but
|
||||
// N-1 for APNG, so the exact count varies by format and only the upper bound matters.
|
||||
var decoderOptions = new DecoderOptions { MaxFrames = (uint)(affordableFrames + 2) };
|
||||
DecoderOptions decoderOptions = ImageSharpDecoding.Options(maxFrames: (uint)(affordableFrames + 2));
|
||||
|
||||
Image image = await Image.LoadAsync(decoderOptions, stream, cancellationToken);
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ public abstract class ImageElementBase(IRemoteImageFetcher remoteImageFetcher) :
|
||||
}
|
||||
else
|
||||
{
|
||||
_sourceImage = await Image.LoadAsync(image!, cancellationToken);
|
||||
_sourceImage = await Image.LoadAsync(ImageSharpDecoding.Options(), image!, cancellationToken);
|
||||
}
|
||||
|
||||
int scaledWidth = _sourceImage.Width;
|
||||
|
||||
+20
-6
@@ -2606,7 +2606,16 @@ The advisories that prompted this were resolved in ersatztv#8 (NCalcSync→6.x;
|
||||
bundle 3.x) and ersatztv#314 (**Microsoft.OpenApi 2.0.0→2.7.5**, GHSA-v5pm-xwqc-g5wc High —
|
||||
direct-pinned in `ErsatzTV.csproj` over the 2.0.0 that `Microsoft.AspNetCore.OpenApi` +
|
||||
`Scalar.AspNetCore` pull transitively; the SQLitePCLRaw override pattern; regenerates the
|
||||
OpenAPI doc byte-identically). The NU1901-1903 demotion is **kept by design**: criticals (NU1904)
|
||||
OpenAPI doc byte-identically) and ersatztv#929 (**System.Security.Cryptography.Pkcs 9.0.4→9.0.21**,
|
||||
GHSA-846f-f764-76mx High — direct-referenced in `ErsatzTV.Infrastructure.csproj`, the only project
|
||||
that references `EFCore.BulkExtensions`, whose Oracle/SqlServer providers pull it in). **Override a
|
||||
transitive with a direct reference, not `CentralPackageTransitivePinningEnabled`**: the central
|
||||
transitive pin was measured 2026-10-27 to fail restore with NU1109 in every project, because it pins
|
||||
every transitive that has a `<PackageVersion>`, and the exact `Microsoft.Extensions.DependencyModel`
|
||||
`[8.0.2]` sits below the `>= 9.0.x` that EF Core requires. **SixLabors.ImageSharp is held at 3.1.12**:
|
||||
every patched release fails a Release build without a Six Labors license key — rationale, the
|
||||
per-advisory bounds and the TIFF decoder exclusion are in `security.imagesharp-3x-tiff-decoder-disabled`.
|
||||
The NU1901-1903 demotion is **kept by design**: criticals (NU1904)
|
||||
still hard-block, while low/moderate/high advisories surface as warnings + via the weekly scan and
|
||||
Renovate security PRs, rather than breaking unrelated PRs the moment a new transitive
|
||||
advisory drops.
|
||||
@@ -2619,9 +2628,11 @@ greps for the "has the following vulnerable packages" marker and fails the run i
|
||||
Detection only — it surfaces advisories on a schedule, a Gitea-native stand-in for
|
||||
Dependabot; it does **not** open update PRs (that's Renovate — server-management#484).
|
||||
Gitea registers `schedule` triggers only from the default branch, so the cron starts
|
||||
after merge to `main`; use `workflow_dispatch` to run on demand. It went **green** once
|
||||
ersatztv#8 cleared the NCalcSync/SQLitePCLRaw advisories — a red run now means a **new**
|
||||
advisory has appeared. (ersatztv#14, ersatztv#8)
|
||||
after merge to `main`; use `workflow_dispatch` to run on demand. Since 2026-10-27 it is **red by
|
||||
design on `SixLabors.ImageSharp` 3.1.12** until a Six Labors license key allows the patched line
|
||||
(`security.imagesharp-3x-tiff-decoder-disabled`); `dotnet list` ignores `NuGetAuditSuppress`, so
|
||||
the run cannot be greened by suppression. Read a red run's log for a package OTHER than ImageSharp:
|
||||
that is a new advisory. (ersatztv#14, ersatztv#8, ersatztv#929)
|
||||
|
||||
**Renovate (automated update PRs)** — `.gitea/workflows/renovate.yml` runs self-hosted
|
||||
[Renovate](https://docs.renovatebot.com) weekly (cron `0 3 * * 1`) + on `workflow_dispatch`,
|
||||
@@ -2639,8 +2650,11 @@ github.com PAT for changelogs — named `GH_`, **not** `GITHUB_`, a prefix Gitea
|
||||
**Patch** bumps to test/dev-only packages (NUnit\*, NSubstitute, Shouldly, coverlet,
|
||||
`Microsoft.NET.Test.Sdk`, `Testably.Abstractions*`, threading analyzer) **auto-merge** once
|
||||
the `Build & test (.NET)` check passes — branch protection on `main` requires that context;
|
||||
everything else is manual review (ersatztv is prod-bearing). Range-pinned packages (e.g. EF
|
||||
Core `[9.0.x,10)`) are respected — no v10 jump. PR volume is throttled (`prConcurrentLimit`
|
||||
everything else is manual review (ersatztv is prod-bearing). A version range in
|
||||
`Directory.Packages.props` does **not** hold Renovate back: the Dependency Dashboard (#22,
|
||||
2026-10-27) offers EF Core `[9.0.12,10)` → v10 and `MediatR [12.5.0]` → `[14.2.0]`. A package that
|
||||
must not move gets an `allowedVersions` packageRule in `renovate.json` (today: `SixLabors.ImageSharp`,
|
||||
`security.imagesharp-3x-tiff-decoder-disabled`). PR volume is throttled (`prConcurrentLimit`
|
||||
5 + `config:recommended`'s `prHourlyLimit` 2); tick a dashboard checkbox or raise the limits
|
||||
to drain faster. `workflow_dispatch` defaults to a safe **dry run**. Cross-repo rollout
|
||||
tracked in server-management#484. (server-management#484)
|
||||
|
||||
@@ -184,6 +184,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `security.corp-same-origin` | `SecurityHeadersMiddleware` sends `Cross-Origin-Resource-Policy: same-origin` on every response including `/docs`/`/openapi`, blocking cross-origin `no-cors` embedding without affecting allowed CORS-mode fetches or server-side Jellyfin `/iptv/*` requests. | 2026-07-13 | [link](records/security/corp-same-origin.md) |
|
||||
| `security.csp-permissions-policy` | `SecurityHeadersMiddleware` sends an enforcing (not report-only) `Content-Security-Policy` (no `unsafe-inline`/`unsafe-eval`; the one inline theme-bootstrap script allow-listed by hash) and a deny-all `Permissions-Policy` on the SPA/`/api`/`/artwork`/`/iptv`; `/docs` and `/openapi` keep only the baseline headers, excluded from CSP because Scalar needs inline bootstrap. | 2026-07-12 | [link](records/security/csp-permissions-policy.md) |
|
||||
| `security.fail-closed-api-auth` | Every mutating `/api` request requires `X-Api-Key` (no open mode); reads are gated by `Api:RequireKeyForReads` (default true) OR `[RequiresApiKey]` on sensitive controllers; CORS is an exact-origin allowlist (`ApiCors`); `ForwardedHeaders` trust stays configurable but defaults to trust-all-with-warning. | 2026-07-12 | [link](records/security/fail-closed-api-auth.md) |
|
||||
| `security.imagesharp-3x-tiff-decoder-disabled` | SixLabors.ImageSharp stays on 3.1.12 until a Six Labors license key is provisioned for CI and the image build. Every ImageSharp decode (Identify and Load, remote and local) passes `ImageSharpDecoding.Options(...)`, whose configuration registers every format `Configuration.Default` does EXCEPT TIFF, and nothing outside `ImageSharpDecoding` assigns a decode configuration, reads `Configuration.Default` or touches an `ImageFormatManager`; no code references the TIFF codec, ICC profile types or the histogram-equalization processors. `ImageSharpSurfaceTests` enforces all of it over the IL of every production assembly that can reach ImageSharp. Revisit trigger: a provisioned key — then move to the patched line, drop the Renovate hold and re-measure the #511 decoder behaviours. | 2026-10-27 | [link](records/security/imagesharp-3x-tiff-decoder-disabled.md) |
|
||||
| `security.iptv-access-token-transport` | The `/iptv` `?access_token=` value is percent-encoded (`Uri.EscapeDataString`) everywhere it is interpolated into an M3U/HLS/XMLTV URL (XMLTV additionally XML-escapes the encoded value), so a structural character can't malform the manifest or guide; Serilog logs a scrubbed request path (`access_token` → `***` via `IncludeQueryInRequestPath = false` + a `RequestPathScrubbed` enricher), so a 5xx/Debug `/iptv` request never writes the token; and every dynamic token-bearing `/iptv` manifest (`channels.m3u`, `xmltv.xml`, the HLS multi-variant/media playlists) returns `Cache-Control: private, no-store`. | 2026-07-23 | [link](records/security/iptv-access-token-transport.md) |
|
||||
| `security.iptv-browser-token` | Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), the browser SPA obtains a short-lived, globally-scoped `/iptv/*` access token from an authenticated `GET /api/v1/auth/iptv-token` and appends it as `?access_token=`; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via `JWT:BrowserTokenLifetimeMinutes`. | 2026-07-22 | [link](records/security/iptv-browser-token.md) |
|
||||
| `security.session-auth-dual-credential` | `ApiAuthorizationFilter` accepts a request when a valid `X-Api-Key` matches OR the principal is an authenticated session (cookie `ctv-session`, `HttpOnly`/`SameSite=Lax`); session-authenticated mutations require the presence-only `X-CSRF` header or are rejected 403. This narrows the OIDC-inert sub-claim of `security.blazor-removal-auth-posture` (#206) — the rest of that record's auth-surface enumeration still holds. | 2026-07-12 | [link](records/security/session-auth-dual-credential.md) |
|
||||
|
||||
@@ -57,7 +57,8 @@ image descriptor exceeds its logical screen is clamped by the decoder, verified)
|
||||
frames of that size the budget affords; passes that to **`DecoderOptions.MaxFrames`**, which the
|
||||
decoder enforces regardless of what the header claimed; and then **re-verifies the real
|
||||
`image.Frames.Count`** after decoding, disposing and rejecting if it is over. `MaxFrames` was
|
||||
measured as honored by every animated decoder in play (APNG, GIF, WebP, TIFF), which is what makes
|
||||
measured on 3.1.12 as honored by every animated decoder then in play (APNG, GIF, WebP, TIFF; TIFF is
|
||||
no longer decodable at all — `security.imagesharp-3x-tiff-decoder-disabled`), which is what makes
|
||||
it a real bound rather than another advisory one. The code asks for `affordable + 2` so an
|
||||
animation exactly at the limit still decodes in full while anything over it is visible to the
|
||||
post-decode check; the slop is at most two frames, since `MaxFrames = N` yields `N` frames for
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
key: security.imagesharp-3x-tiff-decoder-disabled
|
||||
title: 2026-10-27 — ImageSharp stays on 3.1.12 because every patched release needs a license key to build in Release; every ImageSharp decode goes through a TIFF-blind configuration (#929)
|
||||
status: active
|
||||
since: '2026-10-27'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'SixLabors.ImageSharp stays on 3.1.12 until a Six Labors license key is provisioned for CI and the image build. Every ImageSharp decode (Identify and Load, remote and local) passes `ImageSharpDecoding.Options(...)`, whose configuration registers every format `Configuration.Default` does EXCEPT TIFF, and nothing outside `ImageSharpDecoding` assigns a decode configuration, reads `Configuration.Default` or touches an `ImageFormatManager`; no code references the TIFF codec, ICC profile types or the histogram-equalization processors. `ImageSharpSurfaceTests` enforces all of it over the IL of every production assembly that can reach ImageSharp. Revisit trigger: a provisioned key — then move to the patched line, drop the Renovate hold and re-measure the #511 decoder behaviours.'
|
||||
signals: 'ImageSharp upgrade, ImageSharp 4, Six Labors license key, SixLaborsLicenseKey, No Six Labors license found, Release build fails, Renovate ImageSharp PR, vulnerable ImageSharp, TIFF decoder disabled, UnknownImageFormatException, ICC profile, dotnet list package --vulnerable, NuGetAuditSuppress · paths: `ErsatzTV.Infrastructure/Images/ImageSharpDecoding.cs`, `RemoteImageValidator.DecodeAndValidate`, `ImageElementBase.LoadImage`, `ImageSharpDecodingTests`, `ErsatzTV.Architecture.Tests/ImageSharpSurfaceTests.cs`, `Directory.Packages.props`, `renovate.json` · issues: #929, #923, #511, #525, #924'
|
||||
mechanics: '`ImageSharpDecoding.Configuration` is a fresh `Configuration` built from the format modules, NOT `Configuration.Default.Clone()` — Clone is shallow, so removing a detector from it would remove it from Default for the whole process'
|
||||
---
|
||||
|
||||
**Why 3.x is kept.** The ImageSharp releases that patch the October 2026 advisories — 3.2.0 on the
|
||||
3.x line, 4.1.2 and 4.1.3 on 4.x — ship an MSBuild target
|
||||
(`SixLabors_ValidateLicense`, before `CoreCompile`) that fails a non-Debug build unless
|
||||
`SixLaborsLicenseKey`, `SixLaborsLicenseFile` or a `sixlabors.lic` is present. Measured 2026-10-27: `dotnet build ErsatzTV.Infrastructure -c Release` errors with
|
||||
`No Six Labors license found` on 3.2.0 and on 4.1.3; a Debug build only warns. The 4.0.0 and 4.1.2
|
||||
packages carry the same `SixLabors.Licensing` task; 3.1.12 is the newest release without it. CI's `test`/`build`
|
||||
jobs and the Docker image all build Release, so a bare version bump (Renovate #923) turns every PR
|
||||
red. A key is application-based (Six Labors offers a free Community licence below USD 1M revenue),
|
||||
so obtaining one is an owner decision, not a code change. The licence TEXT is unchanged across
|
||||
3.1.12/3.2.0/4.1.3 (Six Labors Split License 1.0, byte-identical) — the build gate is what is new.
|
||||
|
||||
Disabling the target, or setting a placeholder key, is **rejected**: it defeats a licence check
|
||||
rather than satisfying it.
|
||||
|
||||
**What bounds each 3.1.12 advisory** (`dotnet list package --vulnerable --include-transitive`,
|
||||
2026-10-27; all fixed in 3.2.0):
|
||||
|
||||
| Advisory | Where the defect is | Why ErsatzTV does not reach it |
|
||||
|---|---|---|
|
||||
| GHSA-v76p-62qx-wwq2 (High) | TIFF decoder, tiled fax heap OOB write | TIFF is not detectable by `ImageSharpDecoding.Configuration`; a TIFF throws `UnknownImageFormatException` before any TIFF code runs |
|
||||
| GHSA-wmxv-xphr-5c9g (Moderate) | TIFF decoder, BigTIFF IFD loop | same — and the Identify pass, which walks the IFD, is the pass the tests pin |
|
||||
| GHSA-jjfr-hcj7-qf5w, GHSA-j9gm-c75j-xc9q (High) | TIFF CCITT T6/T4 **encoder** | production code never encodes TIFF (only test fixtures do) |
|
||||
| GHSA-j3p4-wp97-rph4 (High) | `HistogramEqualization` processor | never called; the only processor is `Resize` |
|
||||
| GHSA-gwg2-r3hj-4w44 (Moderate) | ICC CLUT parser | two halves. ErsatzTV never reads `IccProfile.Entries`, the accessor that runs the parser. And on 3.1.12 decode, `CloneFrame`, `Resize` and `CloneAs` carry a profile through without parsing it — ImageSharp's own runtime behaviour, measured 2026-10-28 |
|
||||
|
||||
The ImageSharp decode surface is exactly `RemoteImageValidator.DecodeAndValidate` (remote logos at
|
||||
render time, logo URLs saved via #525, uploaded artwork) and the local-path load in
|
||||
`ImageElementBase.LoadImage`. Artwork elsewhere goes through SkiaSharp, and uploads are already
|
||||
clamped to PNG/JPEG/GIF/WebP by `ImageContentTypes`, so the only input this rejects is a remote-URL
|
||||
or on-disk TIFF graphic, which now disables its element the same way an unreachable URL does.
|
||||
|
||||
**What holds the bounds.** Every bound in the table except one half of gwg2 is a property of how
|
||||
ErsatzTV calls ImageSharp, so each is enforced over the compiled code rather than left to review;
|
||||
the gwg2 runtime half is pinned by allocation (below).
|
||||
`ImageSharpSurfaceTests` (`ErsatzTV.Architecture.Tests`) reads the IL of every non-test project in
|
||||
`ErsatzTV.sln` whose project-reference closure contains the ImageSharp package — the population is
|
||||
derived from the solution and csproj graph, and a project that joins it without being referenced
|
||||
by the test project fails the fixture. It rejects four shapes:
|
||||
|
||||
- an ImageSharp decode entry point (`Load*`, `Identify*`, `DetectFormat*`, `Decode*`) called
|
||||
without `DecoderOptions`;
|
||||
- a `DecoderOptions` constructed outside `ImageSharpDecoding`;
|
||||
- outside `ImageSharpDecoding`, a call to `DecoderOptions.set_Configuration`, a read of
|
||||
`Configuration.Default`, or any reference to `ImageFormatManager`. A decoder fetched from a
|
||||
format registry decodes its own format whatever configuration the options it is handed carry, so
|
||||
passing the right options is not enough on its own. `DecoderOptions.Configuration` is init-only:
|
||||
reassigning it on a returned instance does not compile (CS8852, measured 2026-10-27), and an
|
||||
object initializer is caught twice, by this rule and the previous one;
|
||||
- any reference into the `Formats.Tiff`, `Metadata.Profiles.Icc` or
|
||||
`Processing.Processors.Normalization` namespaces, the `HistogramEqualizationExtensions` type, or an
|
||||
ImageSharp member whose name contains `Tiff` or `HistogramEqualization`
|
||||
(`ImageExtensions.SaveAsTiff` carries no TIFF type in its signature).
|
||||
|
||||
Each check also asserts it sees something known to exist — the infrastructure and host assemblies
|
||||
in the population, the two decode sites, the one `DecoderOptions` construction and its
|
||||
`Configuration` assignment, the `Resize` call inside a lambda — so an empty scan cannot pass.
|
||||
What an IL scan cannot see: reflection, and a format decoder's own static `Instance` (each decodes
|
||||
only its own format, and TIFF's is in the banned namespace).
|
||||
|
||||
`ImageSharpDecodingTests` derives the expected format list from `Configuration.Default` minus
|
||||
TIFF, so a package bump that adds a format reddens rather than silently narrowing the decoder; a
|
||||
header-only TIFF declaring 30000x30000 exercises the Identify pass (a TIFF-aware Identify would
|
||||
report the size and fail the pixel budget instead). The watermark fixture exercises the local and
|
||||
remote element paths with PNG twins. Identify rejects a TIFF before Load runs, so these
|
||||
behavioural fixtures exercise the Identify pass; that the Load pass uses the same options is held
|
||||
by `ImageSharpSurfaceTests`.
|
||||
|
||||
The gwg2 runtime half is ImageSharp code an IL scan of ErsatzTV cannot see, so it is pinned by
|
||||
behaviour. A 208-byte ICC profile declares an `mft2` CLUT of 255 grid points over 3 inputs, and it is
|
||||
carried in every format whose decoder reads a profile: BMP, JPEG, PNG and WebP. That carrier list is
|
||||
checked rather than trusted: each format the decode configuration registers is round-tripped with
|
||||
the profile through its default encoder, and the formats that come back carrying it must be exactly
|
||||
the list, so a version that adds ICC to another format reddens. 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).
|
||||
`ImageSharpDecodingTests` sends each carrier through the validator plus `CloneFrame`/`Resize`/`CloneAs`,
|
||||
and the watermark fixture through a real remote `WatermarkElement`; both fail above 16 MiB allocated.
|
||||
The validator cases assert that the profile is still attached after decode, `CloneFrame`, `Resize`
|
||||
and `CloneAs`, so a decoder that drops it cannot pass them vacuously. The element case cannot see
|
||||
its private images and asserts only the allocation; it shares the validator cases' fixture. The
|
||||
positive control reads `Entries` on the same profile and must allocate over 64 MiB (about 127 MiB on
|
||||
3.1.12). So a version that starts parsing profiles on decode or clone reddens.
|
||||
|
||||
One shape is outside every check: a `Save(path)` that picks its
|
||||
encoder from the file extension. There is no ImageSharp encode in production code (2026-10-27).
|
||||
|
||||
**The scheduled scan stays red on ImageSharp until the revisit.** `NuGetAuditSuppress` silences the
|
||||
NU1902/NU1903 restore warnings but `dotnet list package --vulnerable` ignores it (measured
|
||||
2026-10-27: a suppressed GHSA still listed), so suppression would hide the advisory from build logs
|
||||
without making `dependency-scan.yml` green. None is added; the advisories stay visible.
|
||||
|
||||
**Renovate is held too.** A `renovate.json` packageRule limits `SixLabors.ImageSharp` to `3.1.x`
|
||||
for ordinary update PRs. A version range in `Directory.Packages.props` would not: the Dependency
|
||||
Dashboard (#22, 2026-10-27) offers `MediatR [12.5.0]` → `[14.2.0]` and EF Core `[9.0.12,10)` → v10
|
||||
for the existing range pins. Whether the rule also holds Renovate's OSV security PR (#923) is
|
||||
unmeasured — Renovate reads its config from `main`, so the rule cannot be probed before merge.
|
||||
|
||||
**On revisit.** With a key provisioned (a CI secret passed as `SixLaborsLicenseKey`, and the same
|
||||
for the Docker build), delete the Renovate rule and move to the patched line. The #511 measurements in
|
||||
`ffmpeg.remote-image-fetcher-bounded` (APNG `FrameMetadataCollection.Count == 0`, the default
|
||||
Identify throwing on APNGs, `MaxFrames` honoured per format) were taken on 3.1.12 and must be
|
||||
re-measured on the new version before the workaround comments are trusted. Whether TIFF comes back
|
||||
is a separate call: nothing in the product needs it.
|
||||
@@ -67,7 +67,12 @@ with have sat in these gaps. Treat anything not named here as unassessed, not as
|
||||
the check proves each job was classified, never that the classification is CORRECT; and an
|
||||
`inline` assertion cannot be proven to work without running the job, so those rows carry
|
||||
`Proof: NONE` honestly instead of borrowing a neighbouring file's proof.
|
||||
2. **C# and TypeScript guards** — seven files, none with a row:
|
||||
2. **C# and TypeScript guards** — none with a row. The list is hand-written, so it is a floor and
|
||||
not the population:
|
||||
`ErsatzTV.Architecture.Tests/ImageSharpSurfaceTests.cs` (ersatztv#929 — derives the assemblies
|
||||
that can reach ImageSharp from `ErsatzTV.sln` and the csproj graph and rejects decode,
|
||||
decode-configuration, TIFF, ICC and histogram-equalization call shapes in their IL), its siblings
|
||||
`ErsatzTV.Architecture.Tests/LayeringTests.cs` and `…/ProviderStaticsWiringTests.cs`,
|
||||
`ErsatzTV.Tests/Integration/SearchIndexMutationCoverageTests.cs` (ersatztv#824 — derives the
|
||||
`ISearchIndex` population from the declaring assembly and asserts set equality against a
|
||||
hand-written covered set),
|
||||
|
||||
+2
-1
@@ -18,7 +18,8 @@ before adding tests, not just `docs/contributing.md` §8 (which now just points
|
||||
| `ErsatzTV.Tests` | API controllers + MediatR handlers, plus the SkiaSharp text-overlay rasteriser (`Infrastructure/Graphics/`) | In-memory SQLite fixture: a shared `SqliteConnection("Data Source=:memory:;Foreign Keys=False")` kept open + `EnsureCreatedAsync()` (**not** full migration replay) + `PRAGMA foreign_keys=OFF`, then seed; a tiny `IDbContextFactory` wraps `new TvContext(...)`. ~1,870 tests (approximate on purpose — an exact count goes stale on every PR that adds one; the previous hardcoded 828 was off by over a thousand). |
|
||||
| `ErsatzTV.Core.Tests` | Domain logic, scheduling, IPTV/XMLTV generation | References `ErsatzTV.Application` directly — there is no separate `Application.Tests` project. ~650 tests + 1 skipped under `TZ=UTC` (the Block playout golden additionally skips under a non-UTC `TZ`; see Golden-file nets). |
|
||||
| `ErsatzTV.Scanner.Tests` | Library scanning: scan handlers, folder scanners, NFO readers | Handler tests substitute the folder scanners + `ILibraryRepository` and assert the resulting repository writes (e.g. `ScanLocalLibraryHandlerTests` pins which `LastScan` levels a scan records — ersatztv#264). Fakes/`Testably` back the file-system-facing scanners. ~1,485 tests (approximate on purpose — an exact count goes stale on every PR that adds one). Additionally contains `Core/FFmpeg/TranscodingTests` — `[Explicit]` + `[Combinatorial]`, so it never runs in CI or a plain `dotnet test` (it needs real ffmpeg/hardware) and contributes 0 to that count; run it by name when touching the transcoding pipeline. |
|
||||
| `ErsatzTV.Architecture.Tests` | Layering rules via NetArchTest.eNhancedEdition | Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers. 5 tests. See `docs/contributing.md` §1. |
|
||||
| `ErsatzTV.Infrastructure.Tests` | Infrastructure services tested without a host: remote image fetching, decode bounds and logo caching, the ImageSharp decode configuration, remote stream probing, the Jellyfin API client, entity locking, local statistics, search query parsing, sequential schedule validation, graphics opacity expressions | The HTTP-facing fixtures use the in-process handler and stream doubles in `Streaming/HttpTestDoubles.cs`, never the network. `ImageSharpDecodingTests` derives its expected format list from `Configuration.Default`, so an ImageSharp bump that adds a format reddens; its hostile-ICC cases (and the watermark fixture's) pin by allocation that decode and clone do not parse an ICC profile (`security.imagesharp-3x-tiff-decoder-disabled`). |
|
||||
| `ErsatzTV.Architecture.Tests` | Rules over compiled assemblies: layering (NetArchTest.eNhancedEdition), provider-statics wiring, and the ImageSharp call surface (Mono.Cecil IL scan) | `LayeringTests`: Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers — see `docs/contributing.md` §1. `ImageSharpSurfaceTests` reads the IL of every non-test assembly whose project graph reaches ImageSharp (derived from `ErsatzTV.sln` and the csproj references, so the project references the host `ErsatzTV` too) and rejects the call shapes `security.imagesharp-3x-tiff-decoder-disabled` names. No exact count on purpose — it goes stale on every PR that adds a rule. |
|
||||
| `ErsatzTV.FFmpeg.Tests` | FFmpeg command construction | Build a pipeline, assert the exact rendered arg string (`PipelineBuilderBaseTests.cs`). |
|
||||
| `web/` (vitest) | React SPA unit tests | Run alongside typecheck + build (see below). Collects `src/**`, `web/scripts/**` *and* `web/vite-plugins/**`, but deliberately **excludes** `web/e2e/**` (the Playwright specs — vitest's default `**/*.spec.*` glob would otherwise run them under jsdom). Some files have git prerequisites since ersatztv#819, and the set is not fixed — ersatztv#883 added one. `web/src/api/pageSizeCallSites.guard.test.ts` and `web/src/api/completeAnnotations.guard.test.ts` need a git **checkout** AND, through it, the **binary**: they derive their file population from `git ls-files` via `web/vite-plugins/trackedSourceFiles.ts` rather than a directory walk, and refuse rather than falling back. `web/vite-plugins/trackedSourceFiles.realgit.test.ts` needs the **binary** but no checkout — it builds its own temp repository to prove that derivation by executing it. So it is not checkout-versus-binary: supplying a `.git` alone would not let any of them run. **The suite therefore runs only where git is present, and `docker/Dockerfile` is not such a place** — its web-build stage builds the SPA and does not test it (ersatztv#887). Enumerating the git-dependent files as Docker `--exclude`s was tried and REVERSED: that list is a population nothing derives, it went stale the first time a guard was added, and the resulting red is unreachable on a PR — `Build & push image (amd64)` is `if: github.event_name != 'pull_request'` — so it landed on `main` and on the release tag instead. The image is gated on `docker-build.yml`'s `test` job running the whole suite on a real checkout, held by `scripts/tests/test_image_build_delegates_the_spa_suite.py`. |
|
||||
| `web/e2e/` (Playwright) | UI-interactive E2E flows against a **live** instance | Not a unit suite and **not** part of `npm test` — needs a running server, so it runs via `scripts/e2e-ui.sh` (boots its own fresh instance) and in CI as a step of the `functional-e2e` job. Headless Chromium, `serial`, `retries: 0`. Scope rule: assert only what the curl harness structurally cannot. See `docs/e2e-local.md` → "UI-E2E harness". |
|
||||
|
||||
+6
-1
@@ -41,7 +41,7 @@
|
||||
"groupName": "Microsoft.AspNetCore"
|
||||
},
|
||||
{
|
||||
"description": "Group Entity Framework Core + providers (held to v9 by the range pins in Directory.Packages.props)",
|
||||
"description": "Group Entity Framework Core + providers. The range pins in Directory.Packages.props hold the build to v9 but do not stop Renovate proposing v10 (docs/ci-cd.md, Renovate)",
|
||||
"matchPackageNames": [
|
||||
"/^Microsoft\\.EntityFrameworkCore/",
|
||||
"/^EFCore\\.BulkExtensions/",
|
||||
@@ -49,6 +49,11 @@
|
||||
],
|
||||
"groupName": "Entity Framework Core"
|
||||
},
|
||||
{
|
||||
"description": "Hold SixLabors.ImageSharp on 3.1.x: every patched release fails a Release build without a Six Labors license key. Delete this rule when the key is provisioned (security.imagesharp-3x-tiff-decoder-disabled, ersatztv#929)",
|
||||
"matchPackageNames": ["SixLabors.ImageSharp"],
|
||||
"allowedVersions": "/^3\\.1\\./"
|
||||
},
|
||||
{
|
||||
"description": "Group the Serilog family into one PR",
|
||||
"matchPackageNames": ["/^Serilog/"],
|
||||
|
||||
Reference in New Issue
Block a user