From 9d79d4bc420b040d6f9a49f4e109cab65fcd5dca Mon Sep 17 00:00:00 2001 From: Jason Finch Date: Sun, 18 Oct 2026 09:33:09 +1000 Subject: [PATCH 1/3] Make the WebP decoder tolerate reserved VP8X bits, trailing data and odd-length metadata chunks Readers must ignore the reserved VP8X flag bits, so they are no longer validated. Chunks are now read only up to the end of the RIFF payload, so bytes after it are ignored rather than parsed as more chunks, and unknown chunks larger than the payload end the parsing instead of becoming a negative skip. A stream that ends inside a chunk header, a 24 bit field or the VP8L header now throws InvalidImageContentException. Metadata chunks with an odd payload length are read without the RIFF padding byte, which is skipped before the next chunk. --- .../Formats/Webp/WebpChunkParsingUtils.cs | 81 +++--- .../Formats/Webp/WebpDecoderCore.cs | 38 ++- .../Formats/WebP/WebpDecoderTests.cs | 262 ++++++++++++++++++ .../Formats/WebP/WebpMetaDataTests.cs | 60 +++- 4 files changed, 394 insertions(+), 47 deletions(-) diff --git a/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs b/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs index 958f4df80b..5a094304a5 100644 --- a/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs +++ b/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs @@ -150,6 +150,14 @@ public static WebpImageInfo ReadVp8LHeader(MemoryAllocator memoryAllocator, Buff // VP8 data size. uint imageDataSize = ReadChunkSize(stream, buffer); + // The header is a one byte signature followed by 32 bits holding the width, height, alpha flag and version. + // The bit reader zero-fills data missing from a truncated stream, so check that the header itself is present + // rather than reporting the zeros as an invalid signature. + if (stream.RemainingBytes < 5) + { + WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the VP8L header"); + } + Vp8LBitReader bitReader = new(stream, imageDataSize, memoryAllocator); // One byte signature, should be 0x2f. @@ -209,14 +217,10 @@ public static WebpImageInfo ReadVp8XHeader(BufferedReadStream stream, Span uint fileSize = ReadChunkSize(stream, buffer); // The first byte contains information about the image features used. + // Its two most significant bits and its least significant bit are reserved. Writers set them to zero, + // but the container specification requires readers to ignore them, so they are not validated. byte imageFeatures = (byte)stream.ReadByte(); - // The first two bit of it are reserved and should be 0. - if (imageFeatures >> 6 != 0) - { - WebpThrowHelper.ThrowImageFormatException("first two bits of the VP8X header are expected to be zero"); - } - // If bit 3 is set, a ICC Profile Chunk should be present. features.IccProfile = (imageFeatures & (1 << 5)) != 0; @@ -259,8 +263,8 @@ public static WebpImageInfo ReadVp8XHeader(BufferedReadStream stream, Span /// The stream to read from. /// The buffer to store the read data into. /// A unsigned 24 bit integer. - /// - /// Thrown if the input stream is not valid. + /// + /// Thrown if the stream ends before the integer. /// public static uint ReadUInt24LittleEndian(BufferedReadStream stream, Span buffer) { @@ -270,7 +274,7 @@ public static uint ReadUInt24LittleEndian(BufferedReadStream stream, Span return BinaryPrimitives.ReadUInt32LittleEndian(buffer); } - throw new ImageFormatException("Invalid Webp data, could not read unsigned 24 bit integer."); + throw new InvalidImageContentException("Invalid Webp data, could not read unsigned 24 bit integer."); } /// @@ -303,12 +307,13 @@ public static unsafe void WriteUInt24LittleEndian(Stream stream, uint data) /// /// The stream to read the data from. /// Buffer to store the data read from the stream. - /// If true, the chunk size is required to be read, otherwise it can be skipped. /// The chunk size in bytes. - /// Thrown if the input stream is not valid. - public static uint ReadChunkSize(BufferedReadStream stream, Span buffer, bool required = true) + /// Thrown if the stream ends inside the size field. + public static uint ReadChunkSize(BufferedReadStream stream, Span buffer) { - ulong chunkSize = ReadPaddedChunkSize(stream, buffer, required); + // Widen before padding so an odd uint.MaxValue payload cannot wrap to zero. + ulong chunkSize = ReadChunkPayloadSize(stream, buffer, true); + chunkSize += chunkSize & 1; // Structural chunk sizes must remain representable by their uint-sized consumers. // Metadata readers retain the wider extent so their recovery can skip it safely. @@ -321,23 +326,24 @@ public static uint ReadChunkSize(BufferedReadStream stream, Span buffer, b } /// - /// Reads a chunk's complete padded extent without wrapping a uint-sized payload length. + /// Reads the payload size stored in a chunk header. RIFF follows an odd-sized payload with a padding byte + /// that belongs to the chunk but not to its payload, so the returned size excludes it. /// /// The input stream. /// The four-byte size buffer. /// Whether an incomplete size field is an error. - /// The padded extent, or remaining bytes when an optional size field is incomplete. - private static ulong ReadPaddedChunkSize(BufferedReadStream stream, Span buffer, bool required) + /// The payload size, or remaining bytes when an optional size field is incomplete. + private static ulong ReadChunkPayloadSize(BufferedReadStream stream, Span buffer, bool required) { if (stream.Read(buffer) is 4) { - uint chunkSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer); - return (ulong)chunkSize + (chunkSize & 1); + return BinaryPrimitives.ReadUInt32LittleEndian(buffer); } if (required) { - throw new ImageFormatException("Invalid Webp data, could not read chunk size."); + // Only a truncated stream leaves fewer than four bytes for the size field. + throw new InvalidImageContentException("Invalid Webp data, could not read chunk size."); } // Return the size of the remaining data in the stream. @@ -349,8 +355,8 @@ private static ulong ReadPaddedChunkSize(BufferedReadStream stream, Span b /// /// The stream to read the data from. /// Buffer to store the data read from the stream. - /// - /// Thrown if the input stream is not valid. + /// + /// Thrown if the stream ends inside the chunk type. /// public static WebpChunkType ReadChunkType(BufferedReadStream stream, Span buffer) { @@ -360,8 +366,8 @@ public static WebpChunkType ReadChunkType(BufferedReadStream stream, Span } // While we ignore unknown chunks we still need a to be a ble to read a chunk type - // known or otherwise from the stream. - throw new ImageFormatException("Invalid Webp data, could not read chunk type."); + // known or otherwise from the stream. Only a truncated stream leaves fewer than four bytes. + throw new InvalidImageContentException("Invalid Webp data, could not read chunk type."); } /// @@ -377,18 +383,18 @@ public static void ReadIccProfile( bool ignoreMetadata, Action executeAncillarySegmentAction) { - ulong chunkSize = ReadPaddedChunkSize(stream, stackalloc byte[4], true); + ulong payloadSize = ReadChunkPayloadSize(stream, stackalloc byte[4], true); - // ICCP precedes image/frame data. Its framing must be readable even when - // metadata is skipped; otherwise there is no safe location to resume decoding. - if (!stream.IsReadRangeValid(stream.Position, chunkSize)) + // ICCP precedes image/frame data. Its framing, including the padding byte of an odd-sized payload, + // must be readable even when metadata is skipped; otherwise there is no safe location to resume decoding. + if (!stream.IsReadRangeValid(stream.Position, payloadSize + (payloadSize & 1))) { WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the ICCP chunk."); } executeAncillarySegmentAction(() => { - byte[]? iccpData = ReadMetadataChunk(stream, chunkSize, ignoreMetadata || metadata.IccProfile != null); + byte[]? iccpData = ReadMetadataChunk(stream, payloadSize, ignoreMetadata || metadata.IccProfile != null); if (iccpData is not null) { IccProfile profile = new(iccpData); @@ -413,8 +419,8 @@ public static void ReadExifProfile( ImageMetadata metadata, bool ignoreMetadata) { - ulong chunkSize = ReadPaddedChunkSize(stream, stackalloc byte[4], !ignoreMetadata); - byte[]? exifData = ReadMetadataChunk(stream, chunkSize, ignoreMetadata || metadata.ExifProfile != null); + ulong payloadSize = ReadChunkPayloadSize(stream, stackalloc byte[4], !ignoreMetadata); + byte[]? exifData = ReadMetadataChunk(stream, payloadSize, ignoreMetadata || metadata.ExifProfile != null); if (exifData is not null) { ExifProfile exifProfile = new(exifData); @@ -445,8 +451,8 @@ public static void ReadXmpProfile( ImageMetadata metadata, bool ignoreMetadata) { - ulong chunkSize = ReadPaddedChunkSize(stream, stackalloc byte[4], !ignoreMetadata); - byte[]? xmpData = ReadMetadataChunk(stream, chunkSize, ignoreMetadata || metadata.XmpProfile != null); + ulong payloadSize = ReadChunkPayloadSize(stream, stackalloc byte[4], !ignoreMetadata); + byte[]? xmpData = ReadMetadataChunk(stream, payloadSize, ignoreMetadata || metadata.XmpProfile != null); if (xmpData is not null) { metadata.XmpProfile = new XmpProfile(xmpData); @@ -458,11 +464,14 @@ public static void ReadXmpProfile( /// Callers execute metadata parsing under the decoder's ancillary integrity policy. /// /// The input stream positioned at the chunk payload. - /// The declared extent including its padding byte. + /// The declared payload length, excluding its padding byte. /// Whether to skip the payload without parsing it. /// The payload, or null when metadata is skipped. - private static byte[]? ReadMetadataChunk(BufferedReadStream stream, ulong paddedLength, bool ignoreMetadata) + private static byte[]? ReadMetadataChunk(BufferedReadStream stream, ulong payloadLength, bool ignoreMetadata) { + // An odd-sized payload is followed by a padding byte that is part of the chunk but not of the payload. + // The next chunk starts after it, clamped to the end of a truncated stream. + ulong paddedLength = payloadLength + (payloadLength & 1); long chunkEnd = stream.Position + (long)Math.Min(paddedLength, (ulong)stream.RemainingBytes); if (ignoreMetadata) { @@ -470,7 +479,7 @@ public static void ReadXmpProfile( return null; } - if (!stream.TryGetReadLength(paddedLength, out int bufferLength)) + if (!stream.TryGetReadLength(payloadLength, out int bufferLength)) { // Ignoring an ancillary error must not make the next parser interpret // this payload as another chunk header. A truncated chunk consumes EOF. @@ -484,6 +493,8 @@ public static void ReadXmpProfile( WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the metadata chunk."); } + // Skip the padding byte, so it is neither returned as payload nor read as the next chunk header. + stream.Position = chunkEnd; return data; } diff --git a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs index c2a4e44653..98cc447a89 100644 --- a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs +++ b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs @@ -52,6 +52,11 @@ internal sealed class WebpDecoderCore : ImageDecoderCore, IDisposable /// private readonly BackgroundColorHandling backgroundColorHandling; + /// + /// The stream position where the RIFF payload ends. Chunk parsing stops here, so bytes after the payload are ignored. + /// + private long riffEnd; + /// /// Initializes a new instance of the class. /// @@ -75,7 +80,7 @@ protected override Image Decode(BufferedReadStream stream, Cance ImageMetadata metadata = new(); Span buffer = stackalloc byte[4]; - uint fileSize = ReadImageHeader(stream, buffer); + uint fileSize = this.ReadImageHeader(stream, buffer); using (this.webImageInfo = this.ReadVp8Info(stream, metadata)) { @@ -140,7 +145,7 @@ protected override Image Decode(BufferedReadStream stream, Cance /// protected override ImageInfo Identify(BufferedReadStream stream, CancellationToken cancellationToken) { - uint fileSize = ReadImageHeader(stream, stackalloc byte[4]); + uint fileSize = this.ReadImageHeader(stream, stackalloc byte[4]); ImageMetadata metadata = new(); using (this.webImageInfo = this.ReadVp8Info(stream, metadata, true)) @@ -170,13 +175,15 @@ protected override ImageInfo Identify(BufferedReadStream stream, CancellationTok } /// - /// Reads and skips over the image header. + /// Reads and skips over the image header, and records where the RIFF payload ends. /// /// The stream to decode from. /// Temporary buffer. /// The file size in bytes. - private static uint ReadImageHeader(BufferedReadStream stream, Span buffer) + private uint ReadImageHeader(BufferedReadStream stream, Span buffer) { + long riffStart = stream.Position; + // Skip FourCC header, we already know its a RIFF file at this point. stream.Skip(4); @@ -185,6 +192,11 @@ private static uint ReadImageHeader(BufferedReadStream stream, Span buffer // The file size in the header is the total size of the chunks that follow plus 4 bytes for the ‘WEBP’ FourCC. uint fileSize = WebpChunkParsingUtils.ReadChunkSize(stream, buffer); + // Bytes after the RIFF payload are not part of the image, so chunks are only read up to its end, + // as libwebp does. A declared size larger than the stream describes a truncated file, so reading + // then stops at the end of the stream instead. + this.riffEnd = Math.Min(riffStart + WebpConstants.ChunkHeaderSize + fileSize, stream.Length); + // Skip 'WEBP' from the header. stream.Skip(4); @@ -221,7 +233,9 @@ private WebpImageInfo ReadVp8Info(BufferedReadStream stream, ImageMetadata metad return info; case WebpChunkType.Vp8X: info = WebpChunkParsingUtils.ReadVp8XHeader(stream, buffer, features); - while (stream.Position < stream.Length) + + // Fewer bytes than a chunk header before the end of the RIFF payload cannot start another chunk. + while (this.riffEnd - stream.Position >= WebpConstants.ChunkHeaderSize) { chunkType = WebpChunkParsingUtils.ReadChunkType(stream, buffer); if (chunkType == WebpChunkType.Vp8) @@ -250,8 +264,9 @@ private WebpImageInfo ReadVp8Info(BufferedReadStream stream, ImageMetadata metad { // Ignore unknown chunks. // These must always fall after the image data so we are safe to always skip them. - uint chunkSize = WebpChunkParsingUtils.ReadChunkSize(stream, buffer, false); - stream.Skip((int)chunkSize); + // A chunk extending past the RIFF payload ends the parsing instead of wrapping to a negative skip. + uint chunkSize = WebpChunkParsingUtils.ReadChunkSize(stream, buffer); + stream.Position = Math.Min(stream.Position + chunkSize, this.riffEnd); } } @@ -334,8 +349,8 @@ private void ParseOptionalChunks(BufferedReadStream stream, ImageMetadata metada return; } - long streamLength = stream.Length; - while (stream.Position < streamLength) + // Fewer bytes than a chunk header before the end of the RIFF payload cannot start another chunk. + while (this.riffEnd - stream.Position >= WebpConstants.ChunkHeaderSize) { // Read chunk header. WebpChunkType chunkType = WebpChunkParsingUtils.ReadChunkType(stream, buffer); @@ -350,8 +365,9 @@ private void ParseOptionalChunks(BufferedReadStream stream, ImageMetadata metada else { // Skip unknown chunks. - uint chunkLength = WebpChunkParsingUtils.ReadChunkSize(stream, buffer, false); - stream.Skip((int)chunkLength); + // A chunk extending past the RIFF payload ends the parsing instead of wrapping to a negative skip. + uint chunkLength = WebpChunkParsingUtils.ReadChunkSize(stream, buffer); + stream.Position = Math.Min(stream.Position + chunkLength, this.riffEnd); } } } diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs index a3e3b81cf0..6c6b4145df 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs @@ -1,12 +1,17 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Buffers.Binary; using System.Runtime.Intrinsics.X86; +using System.Text; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.Metadata.Profiles.Icc; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Tests.TestDataIcc; using SixLabors.ImageSharp.Tests.TestUtilities; using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; using SixLabors.ImageSharp.Tests.TestUtilities.ReferenceCodecs; @@ -19,6 +24,42 @@ namespace SixLabors.ImageSharp.Tests.Formats.Webp; [ValidateDisposedMemoryAllocations] public class WebpDecoderTests { + /// + /// The kinds of encoded files the container tests mutate. + /// + public enum ContainerSample + { + /// + /// A simple lossy file with only a VP8 chunk. + /// + Lossy, + + /// + /// A simple lossless file with only a VP8L chunk. + /// + Lossless, + + /// + /// An extended lossy file with VP8X, ALPH and VP8 chunks. + /// + LossyWithAlpha, + + /// + /// An extended lossless file with VP8X, VP8L and EXIF chunks. + /// + LosslessWithExif, + + /// + /// An extended lossy file with VP8X, VP8, EXIF and XMP chunks. + /// + LossyWithExifAndXmp, + + /// + /// An extended lossless file with VP8X, ICCP and VP8L chunks. + /// + LosslessWithIcc + } + private static MagickReferenceDecoder ReferenceDecoder => MagickReferenceDecoder.WebP; private static string TestImageLossyHorizontalFilterPath => Path.Combine(TestEnvironment.InputImagesDirectoryFullPath, Lossy.AlphaCompressedHorizontalFilter); @@ -621,4 +662,225 @@ public void Decode_WhenColorProfileHandlingIsConvert_ApplyIccProfile(Tes image.CompareToReferenceOutput(provider); Assert.Null(image.Metadata.IccProfile); } + + [Theory] + [InlineData(20, 0x40)] + [InlineData(20, 0x80)] + [InlineData(20, 0x01)] + [InlineData(21, 0xFF)] + [InlineData(23, 0x80)] + public void Decode_Vp8XWithNonZeroReservedFields_MatchesOriginal(int offset, byte bits) + { + // Offset 20 is the VP8X feature flags byte, whose two top bits and lowest bit are reserved. + // Offsets 21 to 23 are the reserved bytes that follow it. + byte[] original = EncodeContainerSample(ContainerSample.LosslessWithExif); + byte[] patched = (byte[])original.Clone(); + patched[offset] |= bits; + + AssertSameDecodedImage(original, patched); + } + + [Theory] + [InlineData(ContainerSample.Lossy)] + [InlineData(ContainerSample.Lossless)] + [InlineData(ContainerSample.LossyWithAlpha)] + [InlineData(ContainerSample.LosslessWithExif)] + [InlineData(ContainerSample.LossyWithExifAndXmp)] + [InlineData(ContainerSample.LosslessWithIcc)] + public void Decode_WithBytesAfterRiffPayload_MatchesOriginal(ContainerSample sample) + { + byte[] original = EncodeContainerSample(sample); + Random random = new(42); + for (int count = 1; count <= 64; count++) + { + byte[] patched = new byte[original.Length + count]; + original.CopyTo(patched, 0); + random.NextBytes(patched.AsSpan(original.Length)); + + AssertSameDecodedImage(original, patched); + } + } + + [Fact] + public void Decode_WithImageChunkAfterRiffPayload_IgnoresIt() + { + // A complete chunk after the RIFF payload is not part of the file, even when it is valid image data. + byte[] original = EncodeContainerSample(ContainerSample.LossyWithAlpha); + using Image other = new(5, 3, Color.White.ToPixel()); + using MemoryStream otherStream = new(); + other.Save(otherStream, new WebpEncoder { FileFormat = WebpFileFormatType.Lossless }); + byte[] otherData = otherStream.ToArray(); + Assert.Equal(12, FindChunk(otherData, "VP8L")); + + byte[] patched = [.. original, .. otherData.AsSpan(12)]; + + AssertSameDecodedImage(original, patched); + } + + [Theory] + [InlineData(0x7FFFFFF0U)] + [InlineData(0xFFFFFFF0U)] + public void Decode_UnknownChunkLargerThanRiffPayload_MatchesOriginal(uint size) + { + // The declared size runs past the end of the RIFF payload, and the second one is negative as an int. + byte[] original = EncodeContainerSample(ContainerSample.LosslessWithExif); + byte[] patched = [.. original, .. "ABCD"u8, 0, 0, 0, 0, 1, 2, 3]; + BinaryPrimitives.WriteUInt32LittleEndian(patched.AsSpan(original.Length + 4), size); + BinaryPrimitives.WriteUInt32LittleEndian(patched.AsSpan(4), (uint)patched.Length - 8); + + AssertSameDecodedImage(original, patched); + } + + [Theory] + [InlineData(ContainerSample.Lossy)] + [InlineData(ContainerSample.Lossless)] + [InlineData(ContainerSample.LossyWithAlpha)] + [InlineData(ContainerSample.LosslessWithExif)] + [InlineData(ContainerSample.LossyWithExifAndXmp)] + [InlineData(ContainerSample.LosslessWithIcc)] + public void Decode_TruncatedContainer_DecodesOrThrowsInvalidImageContentException(ContainerSample sample) + { + byte[] original = EncodeContainerSample(sample); + + // Data missing from the compressed image data is reported by the bitstream decoders rather than the + // container parser, so Load is only checked for cuts outside that data. Identify only reads its header. + bool isLossless = TryFindChunk(original, "VP8L", out int imageChunk); + if (!isLossless) + { + imageChunk = FindChunk(original, "VP8 "); + } + + int bitstreamStart = imageChunk + WebpConstants.ChunkHeaderSize + (isLossless ? 5 : WebpConstants.Vp8FrameHeaderSize); + int bitstreamEnd = imageChunk + WebpConstants.ChunkHeaderSize + BinaryPrimitives.ReadInt32LittleEndian(original.AsSpan(imageChunk + 4)); + + // The format detector needs the complete 12 byte RIFF header to recognize the file. + for (int length = WebpConstants.RiffHeaderSize; length < original.Length; length++) + { + byte[] truncated = original.AsSpan(0, length).ToArray(); + AssertDecodesOrThrowsInvalidImageContent(() => _ = Image.Identify(truncated), length); + if (length < bitstreamStart || length >= bitstreamEnd) + { + AssertDecodesOrThrowsInvalidImageContent( + () => + { + using Image image = Image.Load(truncated); + }, + length); + } + } + } + + /// + /// Encodes a small image whose container has the chunks described by . + /// + /// The kind of file to encode. + /// The encoded file. + private static byte[] EncodeContainerSample(ContainerSample sample) + { + bool lossless = sample is ContainerSample.Lossless or ContainerSample.LosslessWithExif or ContainerSample.LosslessWithIcc; + bool alpha = sample is ContainerSample.LossyWithAlpha or ContainerSample.LosslessWithExif; + + using Image image = new(23, 17); + image.ProcessPixelRows(accessor => + { + for (int y = 0; y < accessor.Height; y++) + { + Span row = accessor.GetRowSpan(y); + for (int x = 0; x < row.Length; x++) + { + byte a = alpha ? (byte)(255 - (x * 9)) : (byte)255; + row[x] = new Rgba32((byte)(x * 11), (byte)(y * 15), (byte)((x + y) * 6), a); + } + } + }); + + if (sample is ContainerSample.LosslessWithExif or ContainerSample.LossyWithExifAndXmp) + { + ExifProfile exifProfile = new(); + exifProfile.SetValue(ExifTag.Software, "ImageSharp"); + image.Metadata.ExifProfile = exifProfile; + } + + if (sample is ContainerSample.LossyWithExifAndXmp) + { + image.Metadata.XmpProfile = new XmpProfile(""u8.ToArray()); + } + + if (sample is ContainerSample.LosslessWithIcc) + { + image.Metadata.IccProfile = new IccProfile(IccTestDataProfiles.ProfileRandomArray); + } + + using MemoryStream stream = new(); + image.Save(stream, new WebpEncoder { FileFormat = lossless ? WebpFileFormatType.Lossless : WebpFileFormatType.Lossy }); + return stream.ToArray(); + } + + /// + /// Asserts that both files decode and identify to the same pixels, metadata and image information. + /// + /// The unmodified file. + /// The modified file. + private static void AssertSameDecodedImage(byte[] expectedData, byte[] actualData) + { + using Image expected = Image.Load(expectedData); + using Image actual = Image.Load(actualData); + ImageComparer.Exact.VerifySimilarity(expected, actual); + Assert.Equal(expected.Metadata.ExifProfile?.ToByteArray(), actual.Metadata.ExifProfile?.ToByteArray()); + Assert.Equal(expected.Metadata.XmpProfile?.ToByteArray(), actual.Metadata.XmpProfile?.ToByteArray()); + Assert.Equal(expected.Metadata.IccProfile?.ToByteArray(), actual.Metadata.IccProfile?.ToByteArray()); + + ImageInfo expectedInfo = Image.Identify(expectedData); + ImageInfo actualInfo = Image.Identify(actualData); + Assert.Equal(expectedInfo.Size, actualInfo.Size); + Assert.Equal(expectedInfo.PixelType.BitsPerPixel, actualInfo.PixelType.BitsPerPixel); + } + + /// + /// Asserts that an operation on a truncated file either succeeds or reports invalid image content. + /// + /// The decoding operation. + /// The truncated file length, reported on failure. + private static void AssertDecodesOrThrowsInvalidImageContent(Action operation, int length) + { + Exception exception = Record.Exception(operation); + Assert.True(exception is null or InvalidImageContentException, $"Truncated to {length} bytes: {exception}"); + } + + /// + /// Gets the offset of the first chunk with the given FourCC. + /// + /// The encoded file. + /// The chunk FourCC. + /// The offset of the chunk header. + private static int FindChunk(byte[] data, string fourCc) + { + Assert.True(TryFindChunk(data, fourCc, out int offset), $"Chunk '{fourCc}' not found."); + return offset; + } + + /// + /// Walks the chunks after the RIFF header to find the first chunk with the given FourCC. + /// + /// The encoded file. + /// The chunk FourCC. + /// The offset of the chunk header, when found. + /// Whether the chunk was found. + private static bool TryFindChunk(byte[] data, string fourCc, out int offset) + { + offset = WebpConstants.RiffHeaderSize; + while (offset + WebpConstants.ChunkHeaderSize <= data.Length) + { + if (Encoding.ASCII.GetString(data, offset, 4) == fourCc) + { + return true; + } + + // Odd-sized payloads are followed by a padding byte. + int size = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset + 4)); + offset += WebpConstants.ChunkHeaderSize + size + (size & 1); + } + + return false; + } } diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs index 324070e4a5..50eb60d90a 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs @@ -8,6 +8,7 @@ using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Metadata.Profiles.Icc; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Tests.TestDataIcc; using SixLabors.ImageSharp.Tests.TestUtilities; // ReSharper disable InconsistentNaming @@ -286,8 +287,9 @@ public void Decode_WithOversizedMetadataChunk_ThrowsInvalidImageContentException [InlineData("XMP ")] public void Decode_WithMetadataChunkLargerThanRemainingData_ThrowsInStrictMode(string chunkType) { + // The RIFF size covers the whole payload, so the metadata chunk is read rather than ignored as trailing data. byte[] payload = Convert.FromHexString( - "524946460000000057454250565038580A00000020000000010000010000494343500000004000000000"); + "524946462200000057454250565038580A00000020000000010000010000494343500000004000000000"); Encoding.ASCII.GetBytes(chunkType, payload.AsSpan(30, 4)); DecoderOptions options = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; @@ -366,6 +368,62 @@ public void Decode_TruncatedIccFraming_RemainsFatal(SegmentIntegrityHandling int Assert.Throws(() => Image.Identify(options, payload)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Decode_OddLengthMetadataChunks_ReadsPayloadsWithoutPaddingByte(bool skipMetadata) + { + // An ICC profile may have any length its header declares, so one extra byte makes it odd. + byte[] icc = [.. IccTestDataProfiles.ProfileRandomArray, 0x2A]; + BinaryPrimitives.WriteUInt32BigEndian(icc, (uint)icc.Length); + + ExifProfile exifProfile = new(); + exifProfile.SetValue(ExifTag.Software, "ImageSharp"); + byte[] exif = exifProfile.ToByteArray(); + if (exif.Length % 2 == 0) + { + // The EXIF reader ignores bytes after the last directory. + exif = [.. exif, 0x2A]; + } + + byte[] xmp = " "u8.ToArray(); + Assert.True(icc.Length % 2 == 1 && exif.Length % 2 == 1 && xmp.Length % 2 == 1); + + using Image source = new(1, 1, new Rgba32(255, 0, 0)); + using MemoryStream encoded = new(); + source.Save(encoded, new WebpEncoder { FileFormat = WebpFileFormatType.Lossless }); + byte[] simple = encoded.ToArray(); + + // RiffHelper writes the declared odd payload length followed by a zero padding byte. Placing chunks after + // each padded payload also checks that the padding is still skipped before the next chunk header. + using MemoryStream output = new(); + output.Write("RIFF\0\0\0\0WEBP"u8); + RiffHelper.WriteChunk(output, (uint)WebpChunkType.Vp8X, [0x2C, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + RiffHelper.WriteChunk(output, (uint)WebpChunkType.Iccp, icc); + output.Write(simple.AsSpan(12)); + RiffHelper.WriteChunk(output, (uint)WebpChunkType.Exif, exif); + RiffHelper.WriteChunk(output, (uint)WebpChunkType.Xmp, xmp); + byte[] payload = output.ToArray(); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)payload.Length - 8); + + DecoderOptions options = new() { SkipMetadata = skipMetadata }; + using Image image = Image.Load(options, payload); + + Assert.Equal(new Rgba32(255, 0, 0), image[0, 0]); + if (skipMetadata) + { + Assert.Null(image.Metadata.IccProfile); + Assert.Null(image.Metadata.ExifProfile); + Assert.Null(image.Metadata.XmpProfile); + } + else + { + Assert.Equal(icc, image.Metadata.IccProfile.ToByteArray()); + Assert.Equal("ImageSharp", image.Metadata.ExifProfile.GetValue(ExifTag.Software).Value); + Assert.Equal(xmp, image.Metadata.XmpProfile.ToByteArray()); + } + } + /// /// Places a metadata declaration around a complete lossless image to test recovery independently of pixel truncation. /// From e071f79e7360765490a4ed868e46516a5f863435 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Tue, 27 Oct 2026 16:15:34 +1000 Subject: [PATCH 2/3] Bound WebP chunk reads to RIFF and animation frames and use stored regression inputs --- .../Formats/Webp/Chunks/WebpFrameData.cs | 5 +- .../Formats/Webp/WebpAnimationDecoder.cs | 183 +++++++---- .../Formats/Webp/WebpChunkParsingUtils.cs | 206 +++++++----- .../Formats/Webp/WebpDecoderCore.cs | 193 ++++++----- .../Formats/WebP/WebpDecoderTests.cs | 309 ++++-------------- .../Formats/WebP/WebpMetaDataTests.cs | 149 +++------ tests/ImageSharp.Tests/TestImages.cs | 5 + .../Webp/container/alpha-crosses-riff.webp | 3 + .../Input/Webp/container/alpha-trailing.webp | 3 + .../Webp/container/anim-crosses-riff.webp | 3 + .../Webp/container/animation-trailing.webp | 3 + .../Webp/container/animation-unknown.webp | 3 + .../Input/Webp/container/animation.webp | 3 + .../Webp/container/exif-crosses-riff.webp | 3 + .../container/exif-oversized-40000000.webp | 3 + .../container/exif-oversized-fffffffe.webp | 3 + .../container/exif-oversized-ffffffff.webp | 3 + .../container/exif-trailing-40000000.webp | 3 + .../container/exif-trailing-fffffffe.webp | 3 + .../container/exif-trailing-ffffffff.webp | 3 + .../Webp/container/frame-crosses-anmf.webp | 3 + .../Webp/container/frame-crosses-riff.webp | 3 + .../Webp/container/icc-crosses-riff.webp | 3 + .../container/iccp-oversized-40000000.webp | 3 + .../container/iccp-oversized-fffffffe.webp | 3 + .../container/iccp-oversized-ffffffff.webp | 3 + .../Webp/container/invalid-icc-animated.webp | 3 + .../Input/Webp/container/invalid-icc.webp | 3 + .../Webp/container/lossless-trailing.webp | 3 + .../Input/Webp/container/lossy-trailing.webp | 3 + .../Webp/container/metadata-trailing.webp | 3 + .../Input/Webp/container/odd-metadata.webp | 3 + tests/Images/Input/Webp/container/odd.icc | 3 + tests/Images/Input/Webp/container/odd.xmp | 1 + .../Input/Webp/container/reserved-fields.webp | 3 + tests/Images/Input/Webp/container/solid.webp | 3 + .../container/truncated-icc-animated.webp | 3 + .../Input/Webp/container/truncated-icc.webp | 3 + .../Input/Webp/container/unknown-maximum.webp | 3 + .../Input/Webp/container/unknown-signed.webp | 3 + .../Webp/container/unknown-unsigned.webp | 3 + .../Webp/container/vp8-crosses-riff.webp | 3 + .../Webp/container/vp8l-crosses-riff.webp | 3 + .../Webp/container/vp8l-short-header.webp | 3 + .../Webp/container/vp8x-crosses-riff.webp | 3 + .../Webp/container/vp8x-short-header.webp | 3 + .../container/xmp-oversized-40000000.webp | 3 + .../container/xmp-oversized-fffffffe.webp | 3 + .../container/xmp-oversized-ffffffff.webp | 3 + .../Webp/container/xmp-trailing-40000000.webp | 3 + .../Webp/container/xmp-trailing-fffffffe.webp | 3 + .../Webp/container/xmp-trailing-ffffffff.webp | 3 + 52 files changed, 612 insertions(+), 571 deletions(-) create mode 100644 tests/Images/Input/Webp/container/alpha-crosses-riff.webp create mode 100644 tests/Images/Input/Webp/container/alpha-trailing.webp create mode 100644 tests/Images/Input/Webp/container/anim-crosses-riff.webp create mode 100644 tests/Images/Input/Webp/container/animation-trailing.webp create mode 100644 tests/Images/Input/Webp/container/animation-unknown.webp create mode 100644 tests/Images/Input/Webp/container/animation.webp create mode 100644 tests/Images/Input/Webp/container/exif-crosses-riff.webp create mode 100644 tests/Images/Input/Webp/container/exif-oversized-40000000.webp create mode 100644 tests/Images/Input/Webp/container/exif-oversized-fffffffe.webp create mode 100644 tests/Images/Input/Webp/container/exif-oversized-ffffffff.webp create mode 100644 tests/Images/Input/Webp/container/exif-trailing-40000000.webp create mode 100644 tests/Images/Input/Webp/container/exif-trailing-fffffffe.webp create mode 100644 tests/Images/Input/Webp/container/exif-trailing-ffffffff.webp create mode 100644 tests/Images/Input/Webp/container/frame-crosses-anmf.webp create mode 100644 tests/Images/Input/Webp/container/frame-crosses-riff.webp create mode 100644 tests/Images/Input/Webp/container/icc-crosses-riff.webp create mode 100644 tests/Images/Input/Webp/container/iccp-oversized-40000000.webp create mode 100644 tests/Images/Input/Webp/container/iccp-oversized-fffffffe.webp create mode 100644 tests/Images/Input/Webp/container/iccp-oversized-ffffffff.webp create mode 100644 tests/Images/Input/Webp/container/invalid-icc-animated.webp create mode 100644 tests/Images/Input/Webp/container/invalid-icc.webp create mode 100644 tests/Images/Input/Webp/container/lossless-trailing.webp create mode 100644 tests/Images/Input/Webp/container/lossy-trailing.webp create mode 100644 tests/Images/Input/Webp/container/metadata-trailing.webp create mode 100644 tests/Images/Input/Webp/container/odd-metadata.webp create mode 100644 tests/Images/Input/Webp/container/odd.icc create mode 100644 tests/Images/Input/Webp/container/odd.xmp create mode 100644 tests/Images/Input/Webp/container/reserved-fields.webp create mode 100644 tests/Images/Input/Webp/container/solid.webp create mode 100644 tests/Images/Input/Webp/container/truncated-icc-animated.webp create mode 100644 tests/Images/Input/Webp/container/truncated-icc.webp create mode 100644 tests/Images/Input/Webp/container/unknown-maximum.webp create mode 100644 tests/Images/Input/Webp/container/unknown-signed.webp create mode 100644 tests/Images/Input/Webp/container/unknown-unsigned.webp create mode 100644 tests/Images/Input/Webp/container/vp8-crosses-riff.webp create mode 100644 tests/Images/Input/Webp/container/vp8l-crosses-riff.webp create mode 100644 tests/Images/Input/Webp/container/vp8l-short-header.webp create mode 100644 tests/Images/Input/Webp/container/vp8x-crosses-riff.webp create mode 100644 tests/Images/Input/Webp/container/vp8x-short-header.webp create mode 100644 tests/Images/Input/Webp/container/xmp-oversized-40000000.webp create mode 100644 tests/Images/Input/Webp/container/xmp-oversized-fffffffe.webp create mode 100644 tests/Images/Input/Webp/container/xmp-oversized-ffffffff.webp create mode 100644 tests/Images/Input/Webp/container/xmp-trailing-40000000.webp create mode 100644 tests/Images/Input/Webp/container/xmp-trailing-fffffffe.webp create mode 100644 tests/Images/Input/Webp/container/xmp-trailing-ffffffff.webp diff --git a/src/ImageSharp/Formats/Webp/Chunks/WebpFrameData.cs b/src/ImageSharp/Formats/Webp/Chunks/WebpFrameData.cs index e7b135dc96..2e58999ed2 100644 --- a/src/ImageSharp/Formats/Webp/Chunks/WebpFrameData.cs +++ b/src/ImageSharp/Formats/Webp/Chunks/WebpFrameData.cs @@ -121,13 +121,14 @@ public long WriteHeaderTo(Stream stream) /// Reads the animation frame header. /// /// The stream to read from. + /// The end of the RIFF payload containing this frame. /// Animation frame data. - public static WebpFrameData Parse(BufferedReadStream stream) + public static WebpFrameData Parse(BufferedReadStream stream, long containerEnd) { Span buffer = stackalloc byte[4]; return new WebpFrameData( - dataSize: WebpChunkParsingUtils.ReadChunkSize(stream, buffer), + dataSize: WebpChunkParsingUtils.ReadChunkSize(stream, buffer, containerEnd, 16, false), x: WebpChunkParsingUtils.ReadUInt24LittleEndian(stream, buffer) * 2, y: WebpChunkParsingUtils.ReadUInt24LittleEndian(stream, buffer) * 2, width: WebpChunkParsingUtils.ReadUInt24LittleEndian(stream, buffer) + 1, diff --git a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs index 323ee30b90..86e29a679e 100644 --- a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs +++ b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs @@ -100,16 +100,18 @@ public WebpAnimationDecoder( /// The webp features. /// The width of the image. /// The height of the image. - /// The size of the image data in bytes. + /// The metadata already read before the ANIM chunk. + /// The end of the RIFF payload. public ImageInfo Identify( BufferedReadStream stream, WebpFeatures features, uint width, uint height, - uint completeDataSize) + ImageMetadata metadata, + long containerEnd) { List framesMetadata = []; - this.metadata = new ImageMetadata(); + this.metadata = metadata; this.webpMetadata = this.metadata.GetWebpMetadata(); this.webpMetadata.RepeatCount = features.AnimationLoopCount; @@ -120,34 +122,32 @@ public ImageInfo Identify( bool ignoreMetadata = this.skipMetadata; Span buffer = stackalloc byte[4]; uint frameCount = 0; - int remainingBytes = (int)completeDataSize; - while (remainingBytes > 0) + while (Math.Min(containerEnd, stream.Length) - stream.Position >= WebpConstants.ChunkHeaderSize) { WebpChunkType chunkType = WebpChunkParsingUtils.ReadChunkType(stream, buffer); - remainingBytes -= 4; switch (chunkType) { case WebpChunkType.FrameData: ImageFrameMetadata frameMetadata = new(); - uint dataSize = ReadFrameInfo(stream, ref frameMetadata); + ReadFrameInfo(stream, frameMetadata, containerEnd); framesMetadata.Add(frameMetadata); - remainingBytes -= (int)dataSize; + frameCount++; break; case WebpChunkType.Iccp: case WebpChunkType.Xmp: case WebpChunkType.Exif: - this.ReadOptionalChunk(stream, chunkType, this.metadata, ignoreMetadata); + this.ReadOptionalChunk(stream, chunkType, this.metadata, ignoreMetadata, containerEnd); break; default: - // Specification explicitly states to ignore unknown chunks. - // We do not support writing these chunks at present. + // Unknown chunks still consume their payload and padding before the next header. + WebpChunkParsingUtils.SkipChunk(stream, buffer, containerEnd); break; } - if (stream.Position == stream.Length || ++frameCount == this.maxFrames) + if (frameCount == this.maxFrames) { break; } @@ -164,20 +164,22 @@ public ImageInfo Identify( /// The webp features. /// The width of the image. /// The height of the image. - /// The size of the image data in bytes. + /// The metadata already read before the ANIM chunk. + /// The end of the RIFF payload. public Image Decode( BufferedReadStream stream, WebpFeatures features, uint width, uint height, - uint completeDataSize) + ImageMetadata metadata, + long containerEnd) where TPixel : unmanaged, IPixel { Image? image = null; ImageFrame? previousFrame = null; WebpFrameData? prevFrameData = null; - this.metadata = new ImageMetadata(); + this.metadata = metadata; this.webpMetadata = this.metadata.GetWebpMetadata(); this.webpMetadata.RepeatCount = features.AnimationLoopCount; @@ -191,46 +193,57 @@ public Image Decode( bool ignoreMetadata = this.skipMetadata; Span buffer = stackalloc byte[4]; uint frameCount = 0; - int remainingBytes = (int)completeDataSize; - - while (remainingBytes > 0) + try { - WebpChunkType chunkType = WebpChunkParsingUtils.ReadChunkType(stream, buffer); - remainingBytes -= 4; - switch (chunkType) + while (Math.Min(containerEnd, stream.Length) - stream.Position >= WebpConstants.ChunkHeaderSize) { - case WebpChunkType.FrameData: - - uint dataSize = this.ReadFrame( - stream, - ref image, - ref previousFrame, - ref prevFrameData, - width, - height, - backgroundPixel); - - remainingBytes -= (int)dataSize; - break; - case WebpChunkType.Iccp: - case WebpChunkType.Xmp: - case WebpChunkType.Exif: - this.ReadOptionalChunk(stream, chunkType, image!.Metadata, ignoreMetadata); - break; - default: + WebpChunkType chunkType = WebpChunkParsingUtils.ReadChunkType(stream, buffer); + switch (chunkType) + { + case WebpChunkType.FrameData: + + this.ReadFrame( + stream, + ref image, + ref previousFrame, + ref prevFrameData, + width, + height, + backgroundPixel, + containerEnd); + + frameCount++; + break; + case WebpChunkType.Iccp: + case WebpChunkType.Xmp: + case WebpChunkType.Exif: + this.ReadOptionalChunk(stream, chunkType, this.metadata, ignoreMetadata, containerEnd); + break; + default: + + // Unknown chunks still consume their payload and padding before the next header. + WebpChunkParsingUtils.SkipChunk(stream, buffer, containerEnd); + break; + } - // Specification explicitly states to ignore unknown chunks. - // We do not support writing these chunks at present. + if (frameCount == this.maxFrames) + { break; + } } - if (stream.Position == stream.Length || ++frameCount == this.maxFrames) + if (image is null) { - break; + WebpThrowHelper.ThrowInvalidImageContentException("Missing WebP animation frame data."); } - } - return image!; + return image; + } + catch + { + image?.Dispose(); + throw; + } } /// @@ -238,19 +251,17 @@ public Image Decode( /// /// The stream from which to read the frame information. Must support reading and seeking. /// A reference to the structure that will be updated with the parsed frame metadata. - /// The number of bytes read from the stream while parsing the frame information. - private static uint ReadFrameInfo(BufferedReadStream stream, ref ImageFrameMetadata frameMetadata) + /// The end of the RIFF payload. + private static void ReadFrameInfo(BufferedReadStream stream, ImageFrameMetadata frameMetadata, long containerEnd) { - WebpFrameData frameData = WebpFrameData.Parse(stream); + WebpFrameData frameData = WebpFrameData.Parse(stream, containerEnd); SetFrameMetadata(frameMetadata, frameData); // Size of the frame header chunk. const int chunkHeaderSize = 16; uint remaining = frameData.DataSize - chunkHeaderSize; - stream.Skip((int)remaining); - - return remaining; + stream.Position += remaining + (frameData.DataSize & 1); } /// @@ -264,19 +275,27 @@ private static uint ReadFrameInfo(BufferedReadStream stream, ref ImageFrameMetad /// The width of the image. /// The height of the image. /// The default background color of the canvas in. - /// The number of bytes read from the stream while parsing the frame information. - private uint ReadFrame( + /// The end of the RIFF payload. + private void ReadFrame( BufferedReadStream stream, ref Image? image, ref ImageFrame? previousFrame, ref WebpFrameData? prevFrameData, uint width, uint height, - TPixel backgroundColor) + TPixel backgroundColor, + long containerEnd) where TPixel : unmanaged, IPixel { - WebpFrameData frameData = WebpFrameData.Parse(stream); - long streamStartPosition = stream.Position; + WebpFrameData frameData = WebpFrameData.Parse(stream, containerEnd); + + // Nested chunks must fit inside this ANMF payload. Bytes in the next frame cannot complete this frame. + long frameEnd = stream.Position + frameData.DataSize - 16; + if (frameEnd - stream.Position < WebpConstants.ChunkHeaderSize || !new Rectangle(0, 0, (int)width, (int)height).Contains(frameData.Bounds)) + { + WebpThrowHelper.ThrowInvalidImageContentException("Invalid WebP animation frame bounds or missing image chunk."); + } + Span buffer = stackalloc byte[4]; WebpChunkType chunkType = WebpChunkParsingUtils.ReadChunkType(stream, buffer); @@ -284,8 +303,13 @@ private uint ReadFrame( byte alphaChunkHeader = 0; if (chunkType is WebpChunkType.Alpha) { - alphaChunkHeader = this.ReadAlphaData(stream); + alphaChunkHeader = this.ReadAlphaData(stream, frameEnd); hasAlpha = true; + if (frameEnd - stream.Position < WebpConstants.ChunkHeaderSize) + { + WebpThrowHelper.ThrowInvalidImageContentException("Missing WebP frame image chunk after alpha data."); + } + chunkType = WebpChunkParsingUtils.ReadChunkType(stream, buffer); } @@ -294,7 +318,7 @@ private uint ReadFrame( switch (chunkType) { case WebpChunkType.Vp8: - webpInfo = WebpChunkParsingUtils.ReadVp8Header(this.memoryAllocator, stream, buffer, features); + webpInfo = WebpChunkParsingUtils.ReadVp8Header(this.memoryAllocator, stream, buffer, features, frameEnd, false); features.Alpha = hasAlpha; features.AlphaChunkHeader = alphaChunkHeader; break; @@ -304,13 +328,19 @@ private uint ReadFrame( WebpThrowHelper.ThrowNotSupportedException("Alpha channel is not supported for lossless webp images."); } - webpInfo = WebpChunkParsingUtils.ReadVp8LHeader(this.memoryAllocator, stream, buffer, features); + webpInfo = WebpChunkParsingUtils.ReadVp8LHeader(this.memoryAllocator, stream, buffer, features, frameEnd, false); break; default: WebpThrowHelper.ThrowImageFormatException("Read unexpected chunk type, should be VP8 or VP8L"); break; } + using WebpImageInfo frameInfo = webpInfo!; + if (frameInfo.Width != frameData.Width || frameInfo.Height != frameData.Height) + { + WebpThrowHelper.ThrowInvalidImageContentException("WebP frame dimensions do not match its image chunk."); + } + ImageFrame currentFrame; if (previousFrame is null) { @@ -347,7 +377,6 @@ private uint ReadFrame( using Buffer2D pixelData = this.DecodeImageFrameData(frameData, webpInfo); DrawDecodedImageFrameOnCanvas(pixelData, currentFrame, interest, blend); - webpInfo?.Dispose(); previousFrame = currentFrame; prevFrameData = frameData; @@ -356,7 +385,7 @@ private uint ReadFrame( this.restoreArea = interest; } - return (uint)(stream.Position - streamStartPosition); + stream.Position = frameEnd + (frameData.DataSize & 1); } /// @@ -372,11 +401,20 @@ private static void SetFrameMetadata(ImageFrameMetadata meta, WebpFrameData fram frameMetadata.DisposalMode = frameData.DisposalMethod; } + /// + /// Reads metadata within RIFF and applies the ancillary recovery policy to complete chunks. + /// + /// The stream positioned at the chunk size. + /// The metadata chunk type. + /// The destination metadata. + /// Whether to skip metadata contents. + /// The end of the RIFF payload. private void ReadOptionalChunk( BufferedReadStream stream, WebpChunkType chunkType, ImageMetadata imageMetadata, - bool ignoreMetadata) + bool ignoreMetadata, + long containerEnd) { switch (chunkType) { @@ -386,13 +424,13 @@ private void ReadOptionalChunk( // precede the frame data, and we cannot safely skip it without successfully reading its size. // ReadIccProfile therefore validates the complete chunk extent before invoking the ancillary // handler. Only errors in the contents of a complete chunk follow that recovery policy. - WebpChunkParsingUtils.ReadIccProfile(stream, imageMetadata, ignoreMetadata, this.executeAncillarySegmentAction); + WebpChunkParsingUtils.ReadIccProfile(stream, imageMetadata, ignoreMetadata, this.executeAncillarySegmentAction, containerEnd); break; case WebpChunkType.Exif: - this.executeAncillarySegmentAction(() => WebpChunkParsingUtils.ReadExifProfile(stream, imageMetadata, ignoreMetadata)); + this.executeAncillarySegmentAction(() => WebpChunkParsingUtils.ReadExifProfile(stream, imageMetadata, ignoreMetadata, containerEnd)); break; case WebpChunkType.Xmp: - this.executeAncillarySegmentAction(() => WebpChunkParsingUtils.ReadXmpProfile(stream, imageMetadata, ignoreMetadata)); + this.executeAncillarySegmentAction(() => WebpChunkParsingUtils.ReadXmpProfile(stream, imageMetadata, ignoreMetadata, containerEnd)); break; } } @@ -401,17 +439,24 @@ private void ReadOptionalChunk( /// Reads the ALPH chunk data. /// /// The stream to read from. - private byte ReadAlphaData(BufferedReadStream stream) + /// The end of the ANMF payload. + private byte ReadAlphaData(BufferedReadStream stream, long containerEnd) { this.alphaData?.Dispose(); - uint alphaChunkSize = WebpChunkParsingUtils.ReadChunkSize(stream, stackalloc byte[4]); + uint alphaChunkSize = WebpChunkParsingUtils.ReadChunkSize(stream, stackalloc byte[4], containerEnd, 1, false); + long chunkEnd = stream.Position + alphaChunkSize + (alphaChunkSize & 1); int alphaDataSize = (int)(alphaChunkSize - 1); this.alphaData = this.memoryAllocator.Allocate(alphaDataSize); byte alphaChunkHeader = (byte)stream.ReadByte(); Span alphaData = this.alphaData.GetSpan(); - _ = stream.Read(alphaData, 0, alphaDataSize); + if (stream.Read(alphaData, 0, alphaDataSize) != alphaDataSize) + { + WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read WebP frame alpha data."); + } + + stream.Position = chunkEnd; return alphaChunkHeader; } diff --git a/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs b/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs index 5a094304a5..402c96542b 100644 --- a/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs +++ b/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs @@ -23,17 +23,20 @@ internal static class WebpChunkParsingUtils /// The buffered read stream. /// The scratch buffer to use while reading. /// The webp features to parse. + /// The end of the containing RIFF or ANMF payload. + /// Whether image-data recovery permits an incomplete compressed payload at EOF. /// Information about this webp image. - public static WebpImageInfo ReadVp8Header(MemoryAllocator memoryAllocator, BufferedReadStream stream, Span buffer, WebpFeatures features) + public static WebpImageInfo ReadVp8Header( + MemoryAllocator memoryAllocator, + BufferedReadStream stream, + Span buffer, + WebpFeatures features, + long containerEnd, + bool allowTruncatedPayload) { - // VP8 data size (not including this 4 bytes). - int bytesRead = stream.Read(buffer, 0, 4); - if (bytesRead != 4) - { - WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the VP8 header"); - } - - uint dataSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer); + uint dataSize = ReadChunkSize(stream, buffer, containerEnd, WebpConstants.Vp8FrameHeaderSize, allowTruncatedPayload); + long chunkEnd = Math.Min(stream.Position + dataSize + (dataSize & 1), stream.Length); + int bytesRead; // Remaining counts the available image data payload. uint remaining = dataSize; @@ -120,6 +123,8 @@ public static WebpImageInfo ReadVp8Header(MemoryAllocator memoryAllocator, Buffe Vp8BitReader bitReader = new(stream, remaining, memoryAllocator, partitionLength) { Remaining = remaining }; + stream.Position = chunkEnd; + return new WebpImageInfo { DataSize = dataSize, @@ -145,58 +150,67 @@ public static WebpImageInfo ReadVp8Header(MemoryAllocator memoryAllocator, Buffe /// The buffered read stream. /// The scratch buffer to use while reading. /// The webp features to parse. - public static WebpImageInfo ReadVp8LHeader(MemoryAllocator memoryAllocator, BufferedReadStream stream, Span buffer, WebpFeatures features) + /// The end of the containing RIFF or ANMF payload. + /// Whether image-data recovery permits an incomplete compressed payload at EOF. + public static WebpImageInfo ReadVp8LHeader( + MemoryAllocator memoryAllocator, + BufferedReadStream stream, + Span buffer, + WebpFeatures features, + long containerEnd, + bool allowTruncatedPayload) { - // VP8 data size. - uint imageDataSize = ReadChunkSize(stream, buffer); - - // The header is a one byte signature followed by 32 bits holding the width, height, alpha flag and version. - // The bit reader zero-fills data missing from a truncated stream, so check that the header itself is present - // rather than reporting the zeros as an invalid signature. - if (stream.RemainingBytes < 5) + // The five-byte VP8L header must fit inside its own chunk, not in following chunks or trailing data. + uint imageDataSize = ReadChunkSize(stream, buffer, containerEnd, 5, allowTruncatedPayload); + long chunkEnd = Math.Min(stream.Position + imageDataSize + (imageDataSize & 1), stream.Length); + Vp8LBitReader bitReader = new(stream, imageDataSize, memoryAllocator); + try { - WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the VP8L header"); - } + // One byte signature, should be 0x2f. + uint signature = bitReader.ReadValue(8); + if (signature != WebpConstants.Vp8LHeaderMagicByte) + { + WebpThrowHelper.ThrowImageFormatException("Invalid VP8L signature"); + } - Vp8LBitReader bitReader = new(stream, imageDataSize, memoryAllocator); + // The first 28 bits of the bitstream specify the width and height of the image. + uint width = bitReader.ReadValue(WebpConstants.Vp8LImageSizeBits) + 1; + uint height = bitReader.ReadValue(WebpConstants.Vp8LImageSizeBits) + 1; + if (width == 0 || height == 0) + { + WebpThrowHelper.ThrowImageFormatException("invalid width or height read"); + } - // One byte signature, should be 0x2f. - uint signature = bitReader.ReadValue(8); - if (signature != WebpConstants.Vp8LHeaderMagicByte) - { - WebpThrowHelper.ThrowImageFormatException("Invalid VP8L signature"); - } + // The alphaIsUsed flag should be set to 0 when all alpha values are 255 in the picture, and 1 otherwise. + // Alpha may have already been set by the VP8X chunk. + features.Alpha |= bitReader.ReadBit(); - // The first 28 bits of the bitstream specify the width and height of the image. - uint width = bitReader.ReadValue(WebpConstants.Vp8LImageSizeBits) + 1; - uint height = bitReader.ReadValue(WebpConstants.Vp8LImageSizeBits) + 1; - if (width == 0 || height == 0) - { - WebpThrowHelper.ThrowImageFormatException("invalid width or height read"); - } + // The next 3 bits are the version. The version number is a 3 bit code that must be set to 0. + // Any other value should be treated as an error. + uint version = bitReader.ReadValue(WebpConstants.Vp8LVersionBits); + if (version != 0) + { + WebpThrowHelper.ThrowNotSupportedException($"Unexpected version number {version} found in VP8L header"); + } - // The alphaIsUsed flag should be set to 0 when all alpha values are 255 in the picture, and 1 otherwise. - // Alpha may have already been set by the VP8X chunk. - features.Alpha |= bitReader.ReadBit(); + stream.Position = chunkEnd; - // The next 3 bits are the version. The version number is a 3 bit code that must be set to 0. - // Any other value should be treated as an error. - uint version = bitReader.ReadValue(WebpConstants.Vp8LVersionBits); - if (version != 0) - { - WebpThrowHelper.ThrowNotSupportedException($"Unexpected version number {version} found in VP8L header"); + return new WebpImageInfo + { + DataSize = imageDataSize, + Width = width, + Height = height, + BitsPerPixel = features.Alpha ? WebpBitsPerPixel.Bit32 : WebpBitsPerPixel.Bit24, + IsLossless = true, + Features = features, + Vp8LBitReader = bitReader + }; } - - return new WebpImageInfo + catch { - DataSize = imageDataSize, - Width = width, - Height = height, - BitsPerPixel = features.Alpha ? WebpBitsPerPixel.Bit32 : WebpBitsPerPixel.Bit24, - IsLossless = true, - Features = features, - Vp8LBitReader = bitReader - }; + bitReader.Dispose(); + throw; + } } /// @@ -211,10 +225,15 @@ public static WebpImageInfo ReadVp8LHeader(MemoryAllocator memoryAllocator, Buff /// The buffered read stream. /// The scratch buffer to use while reading. /// The webp features to parse. + /// The end of the containing RIFF or ANMF payload. /// Information about this webp image. - public static WebpImageInfo ReadVp8XHeader(BufferedReadStream stream, Span buffer, WebpFeatures features) + public static WebpImageInfo ReadVp8XHeader(BufferedReadStream stream, Span buffer, WebpFeatures features, long containerEnd) { - uint fileSize = ReadChunkSize(stream, buffer); + uint size = ReadChunkSize(stream, buffer, containerEnd, 10, false); + if (size != 10) + { + WebpThrowHelper.ThrowInvalidImageContentException("The VP8X payload must contain exactly 10 bytes."); + } // The first byte contains information about the image features used. // Its two most significant bits and its least significant bit are reserved. Writers set them to zero, @@ -236,8 +255,7 @@ public static WebpImageInfo ReadVp8XHeader(BufferedReadStream stream, Span // If bit 7 is set, animation should be present. features.Animation = (imageFeatures & (1 << 1)) != 0; - // 3 reserved bytes should follow which are supposed to be zero. - // No other decoder actually checks this though. + // Readers ignore these three reserved bytes, as they ignore reserved feature bits. stream.Read(buffer, 0, 3); // 3 bytes for the width. @@ -302,27 +320,45 @@ public static unsafe void WriteUInt24LittleEndian(Stream stream, uint data) } /// - /// Reads the chunk size. If Chunk Size is odd, a single padding byte will be added to the payload, - /// so the chunk size will be increased by 1 in those cases. + /// Reads a required chunk size and validates its complete extent before any payload allocation or read. /// - /// The stream to read the data from. - /// Buffer to store the data read from the stream. - /// The chunk size in bytes. - /// Thrown if the stream ends inside the size field. - public static uint ReadChunkSize(BufferedReadStream stream, Span buffer) + /// The stream positioned at the size field. + /// The four-byte size buffer. + /// The end of the containing RIFF or ANMF payload. + /// The minimum payload size required by the chunk header. + /// Whether to retain the bit reader's EOF recovery for compressed image data. + /// The payload size, excluding its RIFF padding byte. + public static uint ReadChunkSize(BufferedReadStream stream, Span buffer, long containerEnd, uint minimumSize, bool allowTruncatedPayload) { - // Widen before padding so an odd uint.MaxValue payload cannot wrap to zero. - ulong chunkSize = ReadChunkPayloadSize(stream, buffer, true); - chunkSize += chunkSize & 1; + ulong size = ReadChunkPayloadSize(stream, buffer, true); + + // Widen before adding padding. The parent end also excludes bytes outside RIFF and adjacent ANMF frames. + ulong paddedSize = size + (size & 1); + if (size < minimumSize || paddedSize > (ulong)(containerEnd - stream.Position) || stream.RemainingBytes < minimumSize) + { + WebpThrowHelper.ThrowInvalidImageContentException("WebP chunk extends beyond its container or has an incomplete header."); + } - // Structural chunk sizes must remain representable by their uint-sized consumers. - // Metadata readers retain the wider extent so their recovery can skip it safely. - if (chunkSize > uint.MaxValue) + // Only compressed image data has EOF recovery. Required headers and parent boundaries remain fatal. + // Bit readers retain their existing zero-filled tail when IgnoreImageData permits that recovery. + if (!allowTruncatedPayload && paddedSize > (ulong)stream.RemainingBytes) { - WebpThrowHelper.ThrowInvalidImageContentException("WebP chunk size exceeds the supported maximum."); + WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the WebP chunk payload."); } - return (uint)chunkSize; + return (uint)size; + } + + /// + /// Skips an unknown chunk without reading beyond its containing RIFF payload. + /// + /// The stream positioned at the size field. + /// The four-byte size buffer. + /// The end of the containing RIFF payload. + public static void SkipChunk(BufferedReadStream stream, Span buffer, long containerEnd) + { + ulong size = ReadChunkPayloadSize(stream, buffer, true); + stream.Position += (long)Math.Min(size + (size & 1), (ulong)(Math.Min(containerEnd, stream.Length) - stream.Position)); } /// @@ -377,24 +413,26 @@ public static WebpChunkType ReadChunkType(BufferedReadStream stream, Span /// The image metadata. /// If true, metadata will be ignored. /// Executes profile parsing under the decoder's integrity policy. + /// The end of the RIFF payload. public static void ReadIccProfile( BufferedReadStream stream, ImageMetadata metadata, bool ignoreMetadata, - Action executeAncillarySegmentAction) + Action executeAncillarySegmentAction, + long containerEnd) { ulong payloadSize = ReadChunkPayloadSize(stream, stackalloc byte[4], true); // ICCP precedes image/frame data. Its framing, including the padding byte of an odd-sized payload, // must be readable even when metadata is skipped; otherwise there is no safe location to resume decoding. - if (!stream.IsReadRangeValid(stream.Position, payloadSize + (payloadSize & 1))) + if (payloadSize + (payloadSize & 1) > (ulong)(Math.Min(containerEnd, stream.Length) - stream.Position)) { WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the ICCP chunk."); } executeAncillarySegmentAction(() => { - byte[]? iccpData = ReadMetadataChunk(stream, payloadSize, ignoreMetadata || metadata.IccProfile != null); + byte[]? iccpData = ReadMetadataChunk(stream, payloadSize, ignoreMetadata || metadata.IccProfile != null, containerEnd); if (iccpData is not null) { IccProfile profile = new(iccpData); @@ -414,13 +452,15 @@ public static void ReadIccProfile( /// The stream to decode from. /// The image metadata. /// If true, metadata will be ignored. + /// The end of the RIFF payload. public static void ReadExifProfile( BufferedReadStream stream, ImageMetadata metadata, - bool ignoreMetadata) + bool ignoreMetadata, + long containerEnd) { ulong payloadSize = ReadChunkPayloadSize(stream, stackalloc byte[4], !ignoreMetadata); - byte[]? exifData = ReadMetadataChunk(stream, payloadSize, ignoreMetadata || metadata.ExifProfile != null); + byte[]? exifData = ReadMetadataChunk(stream, payloadSize, ignoreMetadata || metadata.ExifProfile != null, containerEnd); if (exifData is not null) { ExifProfile exifProfile = new(exifData); @@ -446,13 +486,15 @@ public static void ReadExifProfile( /// The stream to decode from. /// The image metadata. /// If true, metadata will be ignored. + /// The end of the RIFF payload. public static void ReadXmpProfile( BufferedReadStream stream, ImageMetadata metadata, - bool ignoreMetadata) + bool ignoreMetadata, + long containerEnd) { ulong payloadSize = ReadChunkPayloadSize(stream, stackalloc byte[4], !ignoreMetadata); - byte[]? xmpData = ReadMetadataChunk(stream, payloadSize, ignoreMetadata || metadata.XmpProfile != null); + byte[]? xmpData = ReadMetadataChunk(stream, payloadSize, ignoreMetadata || metadata.XmpProfile != null, containerEnd); if (xmpData is not null) { metadata.XmpProfile = new XmpProfile(xmpData); @@ -466,20 +508,21 @@ public static void ReadXmpProfile( /// The input stream positioned at the chunk payload. /// The declared payload length, excluding its padding byte. /// Whether to skip the payload without parsing it. + /// The end of the RIFF payload. /// The payload, or null when metadata is skipped. - private static byte[]? ReadMetadataChunk(BufferedReadStream stream, ulong payloadLength, bool ignoreMetadata) + private static byte[]? ReadMetadataChunk(BufferedReadStream stream, ulong payloadLength, bool ignoreMetadata, long containerEnd) { // An odd-sized payload is followed by a padding byte that is part of the chunk but not of the payload. // The next chunk starts after it, clamped to the end of a truncated stream. ulong paddedLength = payloadLength + (payloadLength & 1); - long chunkEnd = stream.Position + (long)Math.Min(paddedLength, (ulong)stream.RemainingBytes); + long chunkEnd = stream.Position + (long)Math.Min(paddedLength, (ulong)(Math.Min(containerEnd, stream.Length) - stream.Position)); if (ignoreMetadata) { stream.Position = chunkEnd; return null; } - if (!stream.TryGetReadLength(payloadLength, out int bufferLength)) + if (paddedLength > (ulong)(Math.Min(containerEnd, stream.Length) - stream.Position) || !stream.TryGetReadLength(payloadLength, out _)) { // Ignoring an ancillary error must not make the next parser interpret // this payload as another chunk header. A truncated chunk consumes EOF. @@ -487,6 +530,7 @@ public static void ReadXmpProfile( WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the metadata chunk."); } + int bufferLength = (int)payloadLength; byte[] data = new byte[bufferLength]; if (stream.Read(data) != bufferLength) { diff --git a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs index 98cc447a89..fba9c7b83a 100644 --- a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs +++ b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs @@ -80,7 +80,7 @@ protected override Image Decode(BufferedReadStream stream, Cance ImageMetadata metadata = new(); Span buffer = stackalloc byte[4]; - uint fileSize = this.ReadImageHeader(stream, buffer); + this.ReadImageHeader(stream, buffer); using (this.webImageInfo = this.ReadVp8Info(stream, metadata)) { @@ -94,7 +94,13 @@ protected override Image Decode(BufferedReadStream stream, Cance this.backgroundColorHandling, this.ExecuteAncillarySegmentAction); - return animationDecoder.Decode(stream, this.webImageInfo.Features, this.webImageInfo.Width, this.webImageInfo.Height, fileSize); + return animationDecoder.Decode( + stream, + this.webImageInfo.Features, + this.webImageInfo.Width, + this.webImageInfo.Height, + metadata, + this.riffEnd); } // A VP8X header alone describes a canvas, not decodable image data. @@ -145,7 +151,7 @@ protected override Image Decode(BufferedReadStream stream, Cance /// protected override ImageInfo Identify(BufferedReadStream stream, CancellationToken cancellationToken) { - uint fileSize = this.ReadImageHeader(stream, stackalloc byte[4]); + this.ReadImageHeader(stream, stackalloc byte[4]); ImageMetadata metadata = new(); using (this.webImageInfo = this.ReadVp8Info(stream, metadata, true)) @@ -165,7 +171,8 @@ protected override ImageInfo Identify(BufferedReadStream stream, CancellationTok this.webImageInfo.Features, this.webImageInfo.Width, this.webImageInfo.Height, - fileSize); + metadata, + this.riffEnd); } return new ImageInfo( @@ -179,8 +186,7 @@ protected override ImageInfo Identify(BufferedReadStream stream, CancellationTok /// /// The stream to decode from. /// Temporary buffer. - /// The file size in bytes. - private uint ReadImageHeader(BufferedReadStream stream, Span buffer) + private void ReadImageHeader(BufferedReadStream stream, Span buffer) { long riffStart = stream.Position; @@ -190,17 +196,25 @@ private uint ReadImageHeader(BufferedReadStream stream, Span buffer) // Read file size. // The size of the file in bytes starting at offset 8. // The file size in the header is the total size of the chunks that follow plus 4 bytes for the ‘WEBP’ FourCC. - uint fileSize = WebpChunkParsingUtils.ReadChunkSize(stream, buffer); + if (stream.Read(buffer) != 4) + { + WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the RIFF size."); + } + + uint fileSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer); // Bytes after the RIFF payload are not part of the image, so chunks are only read up to its end, - // as libwebp does. A declared size larger than the stream describes a truncated file, so reading - // then stops at the end of the stream instead. - this.riffEnd = Math.Min(riffStart + WebpConstants.ChunkHeaderSize + fileSize, stream.Length); + // as libwebp does. Keep the declared end separate from EOF: IgnoreImageData can recover a compressed + // payload cut short at EOF, but it must never borrow bytes from outside its declared parent. + this.riffEnd = riffStart + WebpConstants.ChunkHeaderSize + fileSize; // Skip 'WEBP' from the header. stream.Skip(4); - return fileSize; + if (fileSize < 12 || Math.Min(this.riffEnd, stream.Length) - stream.Position < WebpConstants.ChunkHeaderSize) + { + WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the first WebP chunk."); + } } /// @@ -219,62 +233,87 @@ private WebpImageInfo ReadVp8Info(BufferedReadStream stream, ImageMetadata metad WebpImageInfo? info = null; WebpFeatures features = new(); - switch (chunkType) + bool allowTruncatedPayload = this.Options.SegmentIntegrityHandling == SegmentIntegrityHandling.IgnoreImageData; + try { - case WebpChunkType.Vp8: - info = WebpChunkParsingUtils.ReadVp8Header(this.memoryAllocator, stream, buffer, features); - webpMetadata.FileFormat = WebpFileFormatType.Lossy; - webpMetadata.ColorType = WebpColorType.Yuv; - return info; - case WebpChunkType.Vp8L: - info = WebpChunkParsingUtils.ReadVp8LHeader(this.memoryAllocator, stream, buffer, features); - webpMetadata.FileFormat = WebpFileFormatType.Lossless; - webpMetadata.ColorType = info.Features?.Alpha == true ? WebpColorType.Rgba : WebpColorType.Rgb; - return info; - case WebpChunkType.Vp8X: - info = WebpChunkParsingUtils.ReadVp8XHeader(stream, buffer, features); - - // Fewer bytes than a chunk header before the end of the RIFF payload cannot start another chunk. - while (this.riffEnd - stream.Position >= WebpConstants.ChunkHeaderSize) - { - chunkType = WebpChunkParsingUtils.ReadChunkType(stream, buffer); - if (chunkType == WebpChunkType.Vp8) - { - info = WebpChunkParsingUtils.ReadVp8Header(this.memoryAllocator, stream, buffer, features); - webpMetadata.FileFormat = WebpFileFormatType.Lossy; - webpMetadata.ColorType = info.Features?.Alpha == true ? WebpColorType.Rgba : WebpColorType.Rgb; - } - else if (chunkType == WebpChunkType.Vp8L) - { - info = WebpChunkParsingUtils.ReadVp8LHeader(this.memoryAllocator, stream, buffer, features); - webpMetadata.FileFormat = WebpFileFormatType.Lossless; - webpMetadata.ColorType = info.Features?.Alpha == true ? WebpColorType.Rgba : WebpColorType.Rgb; - } - else if (WebpChunkParsingUtils.IsOptionalVp8XChunk(chunkType)) + switch (chunkType) + { + case WebpChunkType.Vp8: + info = WebpChunkParsingUtils.ReadVp8Header(this.memoryAllocator, stream, buffer, features, this.riffEnd, allowTruncatedPayload); + webpMetadata.FileFormat = WebpFileFormatType.Lossy; + webpMetadata.ColorType = WebpColorType.Yuv; + return info; + case WebpChunkType.Vp8L: + info = WebpChunkParsingUtils.ReadVp8LHeader(this.memoryAllocator, stream, buffer, features, this.riffEnd, allowTruncatedPayload); + webpMetadata.FileFormat = WebpFileFormatType.Lossless; + webpMetadata.ColorType = info.Features?.Alpha == true ? WebpColorType.Rgba : WebpColorType.Rgb; + return info; + case WebpChunkType.Vp8X: + info = WebpChunkParsingUtils.ReadVp8XHeader(stream, buffer, features, this.riffEnd); + + // Fewer bytes than a chunk header before the end of the RIFF payload cannot start another chunk. + while (Math.Min(this.riffEnd, stream.Length) - stream.Position >= WebpConstants.ChunkHeaderSize) { - // ANIM chunks appear before EXIF and XMP chunks. - // Return after parsing an ANIM chunk - The animated decoder will handle the rest. - bool isAnimationChunk = this.ParseOptionalExtendedChunks(stream, metadata, chunkType, features, ignoreAlpha, buffer); - if (isAnimationChunk) + chunkType = WebpChunkParsingUtils.ReadChunkType(stream, buffer); + if (chunkType == WebpChunkType.Vp8) { - return info; + WebpImageInfo imageInfo = WebpChunkParsingUtils.ReadVp8Header( + this.memoryAllocator, + stream, + buffer, + features, + this.riffEnd, + allowTruncatedPayload); + + info.Dispose(); + info = imageInfo; + webpMetadata.FileFormat = WebpFileFormatType.Lossy; + webpMetadata.ColorType = info.Features?.Alpha == true ? WebpColorType.Rgba : WebpColorType.Rgb; + } + else if (chunkType == WebpChunkType.Vp8L) + { + WebpImageInfo imageInfo = WebpChunkParsingUtils.ReadVp8LHeader( + this.memoryAllocator, + stream, + buffer, + features, + this.riffEnd, + allowTruncatedPayload); + + info.Dispose(); + info = imageInfo; + webpMetadata.FileFormat = WebpFileFormatType.Lossless; + webpMetadata.ColorType = info.Features?.Alpha == true ? WebpColorType.Rgba : WebpColorType.Rgb; + } + else if (WebpChunkParsingUtils.IsOptionalVp8XChunk(chunkType)) + { + // ANIM chunks appear before EXIF and XMP chunks. + // Return after parsing an ANIM chunk - The animated decoder will handle the rest. + bool isAnimationChunk = this.ParseOptionalExtendedChunks(stream, metadata, chunkType, features, ignoreAlpha, buffer); + if (isAnimationChunk) + { + return info; + } + } + else + { + // Unknown chunks do not change the pixels. Widen their padded size before skipping, + // and stop at RIFF when the declaration exceeds the available container bytes. + WebpChunkParsingUtils.SkipChunk(stream, buffer, this.riffEnd); } } - else - { - // Ignore unknown chunks. - // These must always fall after the image data so we are safe to always skip them. - // A chunk extending past the RIFF payload ends the parsing instead of wrapping to a negative skip. - uint chunkSize = WebpChunkParsingUtils.ReadChunkSize(stream, buffer); - stream.Position = Math.Min(stream.Position + chunkSize, this.riffEnd); - } - } - return info; - default: - WebpThrowHelper.ThrowImageFormatException("Unrecognized VP8 header"); - return - new WebpImageInfo(); // this return will never be reached, because throw helper will throw an exception. + return info; + default: + WebpThrowHelper.ThrowImageFormatException("Unrecognized VP8 header"); + return + new WebpImageInfo(); // this return will never be reached, because throw helper will throw an exception. + } + } + catch + { + info?.Dispose(); + throw; } } @@ -305,19 +344,19 @@ private bool ParseOptionalExtendedChunks( // precede the image data, and we cannot safely skip it without successfully reading its size. // ReadIccProfile therefore validates the complete chunk extent before invoking the ancillary // handler. Only errors in the contents of a complete chunk follow that recovery policy. - WebpChunkParsingUtils.ReadIccProfile(stream, metadata, ignoreMetadata, this.ExecuteAncillarySegmentAction); + WebpChunkParsingUtils.ReadIccProfile(stream, metadata, ignoreMetadata, this.ExecuteAncillarySegmentAction, this.riffEnd); break; case WebpChunkType.Exif: - this.ExecuteAncillarySegmentAction(() => WebpChunkParsingUtils.ReadExifProfile(stream, metadata, ignoreMetadata)); + this.ExecuteAncillarySegmentAction(() => WebpChunkParsingUtils.ReadExifProfile(stream, metadata, ignoreMetadata, this.riffEnd)); break; case WebpChunkType.Xmp: - this.ExecuteAncillarySegmentAction(() => WebpChunkParsingUtils.ReadXmpProfile(stream, metadata, ignoreMetadata)); + this.ExecuteAncillarySegmentAction(() => WebpChunkParsingUtils.ReadXmpProfile(stream, metadata, ignoreMetadata, this.riffEnd)); break; case WebpChunkType.AnimationParameter: - ReadAnimationParameters(stream, features, buffer); + ReadAnimationParameters(stream, features, buffer, this.riffEnd); return true; case WebpChunkType.Alpha: @@ -350,24 +389,23 @@ private void ParseOptionalChunks(BufferedReadStream stream, ImageMetadata metada } // Fewer bytes than a chunk header before the end of the RIFF payload cannot start another chunk. - while (this.riffEnd - stream.Position >= WebpConstants.ChunkHeaderSize) + while (Math.Min(this.riffEnd, stream.Length) - stream.Position >= WebpConstants.ChunkHeaderSize) { // Read chunk header. WebpChunkType chunkType = WebpChunkParsingUtils.ReadChunkType(stream, buffer); if (chunkType == WebpChunkType.Exif) { - this.ExecuteAncillarySegmentAction(() => WebpChunkParsingUtils.ReadExifProfile(stream, metadata, ignoreMetadata)); + this.ExecuteAncillarySegmentAction(() => WebpChunkParsingUtils.ReadExifProfile(stream, metadata, ignoreMetadata, this.riffEnd)); } else if (chunkType == WebpChunkType.Xmp) { - this.ExecuteAncillarySegmentAction(() => WebpChunkParsingUtils.ReadXmpProfile(stream, metadata, ignoreMetadata)); + this.ExecuteAncillarySegmentAction(() => WebpChunkParsingUtils.ReadXmpProfile(stream, metadata, ignoreMetadata, this.riffEnd)); } else { // Skip unknown chunks. // A chunk extending past the RIFF payload ends the parsing instead of wrapping to a negative skip. - uint chunkLength = WebpChunkParsingUtils.ReadChunkSize(stream, buffer); - stream.Position = Math.Min(stream.Position + chunkLength, this.riffEnd); + WebpChunkParsingUtils.SkipChunk(stream, buffer, this.riffEnd); } } } @@ -378,10 +416,12 @@ private void ParseOptionalChunks(BufferedReadStream stream, ImageMetadata metada /// The stream to decode from. /// The webp features. /// Temporary buffer. - private static void ReadAnimationParameters(BufferedReadStream stream, WebpFeatures features, Span buffer) + /// The end of the RIFF payload. + private static void ReadAnimationParameters(BufferedReadStream stream, WebpFeatures features, Span buffer, long containerEnd) { features.Animation = true; - uint animationChunkSize = WebpChunkParsingUtils.ReadChunkSize(stream, buffer); + uint animationChunkSize = WebpChunkParsingUtils.ReadChunkSize(stream, buffer, containerEnd, 6, false); + long chunkEnd = stream.Position + animationChunkSize + (animationChunkSize & 1); byte blue = (byte)stream.ReadByte(); byte green = (byte)stream.ReadByte(); byte red = (byte)stream.ReadByte(); @@ -394,6 +434,7 @@ private static void ReadAnimationParameters(BufferedReadStream stream, WebpFeatu } features.AnimationLoopCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer); + stream.Position = chunkEnd; } /// @@ -405,15 +446,17 @@ private static void ReadAnimationParameters(BufferedReadStream stream, WebpFeatu /// Temporary buffer. private void ReadAlphaData(BufferedReadStream stream, WebpFeatures features, bool ignoreAlpha, Span buffer) { - uint alphaChunkSize = WebpChunkParsingUtils.ReadChunkSize(stream, buffer); + uint alphaChunkSize = WebpChunkParsingUtils.ReadChunkSize(stream, buffer, this.riffEnd, 1, false); + long chunkEnd = stream.Position + alphaChunkSize + (alphaChunkSize & 1); if (ignoreAlpha) { - stream.Skip((int)alphaChunkSize); + stream.Position = chunkEnd; return; } features.AlphaChunkHeader = (byte)stream.ReadByte(); int alphaDataSize = (int)(alphaChunkSize - 1); + this.alphaData?.Dispose(); this.alphaData = this.memoryAllocator.Allocate(alphaDataSize); Span alphaData = this.alphaData.GetSpan(); int bytesRead = stream.Read(alphaData, 0, alphaDataSize); @@ -421,6 +464,8 @@ private void ReadAlphaData(BufferedReadStream stream, WebpFeatures features, boo { WebpThrowHelper.ThrowInvalidImageContentException("Not enough data to read the alpha data from the stream"); } + + stream.Position = chunkEnd; } /// diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs index 6c6b4145df..c80019bdef 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs @@ -1,17 +1,12 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using System.Buffers.Binary; using System.Runtime.Intrinsics.X86; -using System.Text; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.Metadata.Profiles.Exif; -using SixLabors.ImageSharp.Metadata.Profiles.Icc; -using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Tests.TestDataIcc; using SixLabors.ImageSharp.Tests.TestUtilities; using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; using SixLabors.ImageSharp.Tests.TestUtilities.ReferenceCodecs; @@ -24,42 +19,6 @@ namespace SixLabors.ImageSharp.Tests.Formats.Webp; [ValidateDisposedMemoryAllocations] public class WebpDecoderTests { - /// - /// The kinds of encoded files the container tests mutate. - /// - public enum ContainerSample - { - /// - /// A simple lossy file with only a VP8 chunk. - /// - Lossy, - - /// - /// A simple lossless file with only a VP8L chunk. - /// - Lossless, - - /// - /// An extended lossy file with VP8X, ALPH and VP8 chunks. - /// - LossyWithAlpha, - - /// - /// An extended lossless file with VP8X, VP8L and EXIF chunks. - /// - LosslessWithExif, - - /// - /// An extended lossy file with VP8X, VP8, EXIF and XMP chunks. - /// - LossyWithExifAndXmp, - - /// - /// An extended lossless file with VP8X, ICCP and VP8L chunks. - /// - LosslessWithIcc - } - private static MagickReferenceDecoder ReferenceDecoder => MagickReferenceDecoder.WebP; private static string TestImageLossyHorizontalFilterPath => Path.Combine(TestEnvironment.InputImagesDirectoryFullPath, Lossy.AlphaCompressedHorizontalFilter); @@ -447,8 +406,15 @@ public void Decode_AnimatedLossy_AlphaBlending_Works(TestImageProvider(TestImageProvider provider) where TPixel : unmanaged, IPixel { - // Just make sure no exception is thrown. The reference decoder fails to load the image. - using Image image = provider.GetImage(WebpDecoder.Instance); + // These stored files end before their declared VP8L payload ends. Local libwebp reports NOT_ENOUGH_DATA. + // Recovery belongs to IgnoreImageData; ignoring ancillary errors must not permit missing image bytes. + DecoderOptions strict = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; + DecoderOptions ancillary = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreAncillary }; + Assert.Throws(() => provider.GetImage(WebpDecoder.Instance, strict)); + Assert.Throws(() => provider.GetImage(WebpDecoder.Instance, ancillary)); + + DecoderOptions options = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreImageData }; + using Image image = provider.GetImage(WebpDecoder.Instance, options); image.DebugSave(provider); } @@ -542,10 +508,11 @@ public void WebpDecoder_CanDecode_Issue2866(TestImageProvider pr [WithFile(Lossless.LossLessCorruptImage3, PixelTypes.Rgba32)] public void WebpDecoder_ThrowImageFormatException_OnInvalidImages(TestImageProvider provider) where TPixel : unmanaged, IPixel => + // Ignore the missing tail so this assertion still tests the invalid compressed color transform. Assert.Throws( () => { - using (provider.GetImage(WebpDecoder.Instance)) + using (provider.GetImage(WebpDecoder.Instance, new DecoderOptions { SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreImageData })) { } }); @@ -663,224 +630,80 @@ public void Decode_WhenColorProfileHandlingIsConvert_ApplyIccProfile(Tes Assert.Null(image.Metadata.IccProfile); } - [Theory] - [InlineData(20, 0x40)] - [InlineData(20, 0x80)] - [InlineData(20, 0x01)] - [InlineData(21, 0xFF)] - [InlineData(23, 0x80)] - public void Decode_Vp8XWithNonZeroReservedFields_MatchesOriginal(int offset, byte bits) - { - // Offset 20 is the VP8X feature flags byte, whose two top bits and lowest bit are reserved. - // Offsets 21 to 23 are the reserved bytes that follow it. - byte[] original = EncodeContainerSample(ContainerSample.LosslessWithExif); - byte[] patched = (byte[])original.Clone(); - patched[offset] |= bits; - - AssertSameDecodedImage(original, patched); - } - - [Theory] - [InlineData(ContainerSample.Lossy)] - [InlineData(ContainerSample.Lossless)] - [InlineData(ContainerSample.LossyWithAlpha)] - [InlineData(ContainerSample.LosslessWithExif)] - [InlineData(ContainerSample.LossyWithExifAndXmp)] - [InlineData(ContainerSample.LosslessWithIcc)] - public void Decode_WithBytesAfterRiffPayload_MatchesOriginal(ContainerSample sample) - { - byte[] original = EncodeContainerSample(sample); - Random random = new(42); - for (int count = 1; count <= 64; count++) - { - byte[] patched = new byte[original.Length + count]; - original.CopyTo(patched, 0); - random.NextBytes(patched.AsSpan(original.Length)); - - AssertSameDecodedImage(original, patched); - } - } - - [Fact] - public void Decode_WithImageChunkAfterRiffPayload_IgnoresIt() - { - // A complete chunk after the RIFF payload is not part of the file, even when it is valid image data. - byte[] original = EncodeContainerSample(ContainerSample.LossyWithAlpha); - using Image other = new(5, 3, Color.White.ToPixel()); - using MemoryStream otherStream = new(); - other.Save(otherStream, new WebpEncoder { FileFormat = WebpFileFormatType.Lossless }); - byte[] otherData = otherStream.ToArray(); - Assert.Equal(12, FindChunk(otherData, "VP8L")); - - byte[] patched = [.. original, .. otherData.AsSpan(12)]; - - AssertSameDecodedImage(original, patched); - } - - [Theory] - [InlineData(0x7FFFFFF0U)] - [InlineData(0xFFFFFFF0U)] - public void Decode_UnknownChunkLargerThanRiffPayload_MatchesOriginal(uint size) - { - // The declared size runs past the end of the RIFF payload, and the second one is negative as an int. - byte[] original = EncodeContainerSample(ContainerSample.LosslessWithExif); - byte[] patched = [.. original, .. "ABCD"u8, 0, 0, 0, 0, 1, 2, 3]; - BinaryPrimitives.WriteUInt32LittleEndian(patched.AsSpan(original.Length + 4), size); - BinaryPrimitives.WriteUInt32LittleEndian(patched.AsSpan(4), (uint)patched.Length - 8); - - AssertSameDecodedImage(original, patched); - } - - [Theory] - [InlineData(ContainerSample.Lossy)] - [InlineData(ContainerSample.Lossless)] - [InlineData(ContainerSample.LossyWithAlpha)] - [InlineData(ContainerSample.LosslessWithExif)] - [InlineData(ContainerSample.LossyWithExifAndXmp)] - [InlineData(ContainerSample.LosslessWithIcc)] - public void Decode_TruncatedContainer_DecodesOrThrowsInvalidImageContentException(ContainerSample sample) - { - byte[] original = EncodeContainerSample(sample); - - // Data missing from the compressed image data is reported by the bitstream decoders rather than the - // container parser, so Load is only checked for cuts outside that data. Identify only reads its header. - bool isLossless = TryFindChunk(original, "VP8L", out int imageChunk); - if (!isLossless) - { - imageChunk = FindChunk(original, "VP8 "); - } - - int bitstreamStart = imageChunk + WebpConstants.ChunkHeaderSize + (isLossless ? 5 : WebpConstants.Vp8FrameHeaderSize); - int bitstreamEnd = imageChunk + WebpConstants.ChunkHeaderSize + BinaryPrimitives.ReadInt32LittleEndian(original.AsSpan(imageChunk + 4)); - - // The format detector needs the complete 12 byte RIFF header to recognize the file. - for (int length = WebpConstants.RiffHeaderSize; length < original.Length; length++) - { - byte[] truncated = original.AsSpan(0, length).ToArray(); - AssertDecodesOrThrowsInvalidImageContent(() => _ = Image.Identify(truncated), length); - if (length < bitstreamStart || length >= bitstreamEnd) - { - AssertDecodesOrThrowsInvalidImageContent( - () => - { - using Image image = Image.Load(truncated); - }, - length); - } - } - } - /// - /// Encodes a small image whose container has the chunks described by . + /// Checks that ignored container fields and data do not change pixels or metadata. /// - /// The kind of file to encode. - /// The encoded file. - private static byte[] EncodeContainerSample(ContainerSample sample) - { - bool lossless = sample is ContainerSample.Lossless or ContainerSample.LosslessWithExif or ContainerSample.LosslessWithIcc; - bool alpha = sample is ContainerSample.LossyWithAlpha or ContainerSample.LosslessWithExif; - - using Image image = new(23, 17); - image.ProcessPixelRows(accessor => - { - for (int y = 0; y < accessor.Height; y++) - { - Span row = accessor.GetRowSpan(y); - for (int x = 0; x < row.Length; x++) - { - byte a = alpha ? (byte)(255 - (x * 9)) : (byte)255; - row[x] = new Rgba32((byte)(x * 11), (byte)(y * 15), (byte)((x + y) * 6), a); - } - } - }); - - if (sample is ContainerSample.LosslessWithExif or ContainerSample.LossyWithExifAndXmp) - { - ExifProfile exifProfile = new(); - exifProfile.SetValue(ExifTag.Software, "ImageSharp"); - image.Metadata.ExifProfile = exifProfile; - } - - if (sample is ContainerSample.LossyWithExifAndXmp) - { - image.Metadata.XmpProfile = new XmpProfile(""u8.ToArray()); - } - - if (sample is ContainerSample.LosslessWithIcc) - { - image.Metadata.IccProfile = new IccProfile(IccTestDataProfiles.ProfileRandomArray); - } - - using MemoryStream stream = new(); - image.Save(stream, new WebpEncoder { FileFormat = lossless ? WebpFileFormatType.Lossless : WebpFileFormatType.Lossy }); - return stream.ToArray(); - } + [Theory] + [InlineData(ContainerDirectory + "odd-metadata.webp", ContainerDirectory + "reserved-fields.webp")] + [InlineData(Lossy.Small02, ContainerDirectory + "lossy-trailing.webp")] + [InlineData(Lossless.NoTransform1, ContainerDirectory + "lossless-trailing.webp")] + [InlineData(Lossy.AlphaCompressedHorizontalFilter, ContainerDirectory + "alpha-trailing.webp")] + [InlineData(ContainerDirectory + "odd-metadata.webp", ContainerDirectory + "metadata-trailing.webp")] + [InlineData(ContainerDirectory + "odd-metadata.webp", ContainerDirectory + "unknown-signed.webp")] + [InlineData(ContainerDirectory + "odd-metadata.webp", ContainerDirectory + "unknown-unsigned.webp")] + [InlineData(ContainerDirectory + "odd-metadata.webp", ContainerDirectory + "unknown-maximum.webp")] + [InlineData(ContainerDirectory + "animation.webp", ContainerDirectory + "animation-trailing.webp")] + [InlineData(ContainerDirectory + "animation.webp", ContainerDirectory + "animation-unknown.webp")] + public void Decode_ContainerVariations_PreservePixelsAndMetadata(string originalFile, string modifiedFile) + { + // Local libwebp confirms identical still pixels and two frames for each animation variant. + // The stored files alter reserved fields, append bytes outside RIFF, or add an unknown chunk. + byte[] original = TestFile.Create(originalFile).Bytes; + byte[] modified = TestFile.Create(modifiedFile).Bytes; + DecoderOptions options = new() { MaxFrames = 2 }; + using Image expected = Image.Load(options, original); + using Image actual = Image.Load(options, modified); - /// - /// Asserts that both files decode and identify to the same pixels, metadata and image information. - /// - /// The unmodified file. - /// The modified file. - private static void AssertSameDecodedImage(byte[] expectedData, byte[] actualData) - { - using Image expected = Image.Load(expectedData); - using Image actual = Image.Load(actualData); ImageComparer.Exact.VerifySimilarity(expected, actual); + Assert.Equal(expected.Frames.Count, actual.Frames.Count); Assert.Equal(expected.Metadata.ExifProfile?.ToByteArray(), actual.Metadata.ExifProfile?.ToByteArray()); Assert.Equal(expected.Metadata.XmpProfile?.ToByteArray(), actual.Metadata.XmpProfile?.ToByteArray()); Assert.Equal(expected.Metadata.IccProfile?.ToByteArray(), actual.Metadata.IccProfile?.ToByteArray()); - ImageInfo expectedInfo = Image.Identify(expectedData); - ImageInfo actualInfo = Image.Identify(actualData); + ImageInfo expectedInfo = Image.Identify(options, original); + ImageInfo actualInfo = Image.Identify(options, modified); Assert.Equal(expectedInfo.Size, actualInfo.Size); - Assert.Equal(expectedInfo.PixelType.BitsPerPixel, actualInfo.PixelType.BitsPerPixel); + Assert.Equal(expectedInfo.FrameMetadataCollection.Count, actualInfo.FrameMetadataCollection.Count); } /// - /// Asserts that an operation on a truncated file either succeeds or reports invalid image content. + /// Rejects headers and image payloads whose declared extent exceeds RIFF. /// - /// The decoding operation. - /// The truncated file length, reported on failure. - private static void AssertDecodesOrThrowsInvalidImageContent(Action operation, int length) - { - Exception exception = Record.Exception(operation); - Assert.True(exception is null or InvalidImageContentException, $"Truncated to {length} bytes: {exception}"); + [Theory] + [InlineData(ContainerDirectory + "vp8-crosses-riff.webp")] + [InlineData(ContainerDirectory + "vp8l-crosses-riff.webp")] + [InlineData(ContainerDirectory + "vp8x-crosses-riff.webp")] + [InlineData(ContainerDirectory + "alpha-crosses-riff.webp")] + [InlineData(ContainerDirectory + "anim-crosses-riff.webp")] + [InlineData(ContainerDirectory + "frame-crosses-riff.webp")] + [InlineData(ContainerDirectory + "vp8l-short-header.webp")] + [InlineData(ContainerDirectory + "vp8x-short-header.webp")] + public void Decode_ChunkOutsideRiff_RejectsInvalidFraming(string fileName) + { + byte[] data = TestFile.Create(fileName).Bytes; + + // These files retain bytes after RIFF. Those bytes must not complete a header or image payload. + // Framing errors cannot be recovered by ignoring image or ancillary segment contents. + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + Assert.Throws(() => Image.Load(options, data)); + Assert.Throws(() => Image.Identify(options, data)); + } } /// - /// Gets the offset of the first chunk with the given FourCC. + /// Rejects an image payload that borrows bytes from the next animation frame. /// - /// The encoded file. - /// The chunk FourCC. - /// The offset of the chunk header. - private static int FindChunk(byte[] data, string fourCc) + [Fact] + public void Decode_FrameImageOutsideAnmf_RejectsInvalidFraming() { - Assert.True(TryFindChunk(data, fourCc, out int offset), $"Chunk '{fourCc}' not found."); - return offset; - } - - /// - /// Walks the chunks after the RIFF header to find the first chunk with the given FourCC. - /// - /// The encoded file. - /// The chunk FourCC. - /// The offset of the chunk header, when found. - /// Whether the chunk was found. - private static bool TryFindChunk(byte[] data, string fourCc, out int offset) - { - offset = WebpConstants.RiffHeaderSize; - while (offset + WebpConstants.ChunkHeaderSize <= data.Length) + // The VP8L payload extends into the next ANMF chunk. Local libwebp rejects this file. + byte[] data = TestFile.Create(ContainerDirectory + "frame-crosses-anmf.webp").Bytes; + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) { - if (Encoding.ASCII.GetString(data, offset, 4) == fourCc) - { - return true; - } - - // Odd-sized payloads are followed by a padding byte. - int size = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset + 4)); - offset += WebpConstants.ChunkHeaderSize + size + (size & 1); + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + Assert.Throws(() => Image.Load(options, data)); } - - return false; } } diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs index 50eb60d90a..2f05139e64 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using System.Buffers.Binary; -using System.Text; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Metadata.Profiles.Exif; @@ -39,8 +37,9 @@ public static IEnumerable TruncatedMetadataOptions() { foreach (SegmentIntegrityHandling integrity in new[] { SegmentIntegrityHandling.Strict, SegmentIntegrityHandling.IgnoreAncillary, SegmentIntegrityHandling.IgnoreImageData }) { - yield return new object[] { chunkType, length, integrity, false }; - yield return new object[] { chunkType, length, integrity, true }; + string fileName = $"{TestImages.Webp.ContainerDirectory}{chunkType.Trim().ToLowerInvariant()}-trailing-{length:x8}.webp"; + yield return new object[] { fileName, integrity, false }; + yield return new object[] { fileName, integrity, true }; } } } @@ -263,34 +262,19 @@ public void Decode_InvalidExifChunk_ThrowsWithStrict() } [Theory] - [InlineData("ICCP", 0xFFFFFFFEU)] - [InlineData("EXIF", 0xFFFFFFFEU)] - [InlineData("XMP ", 0xFFFFFFFEU)] - [InlineData("ICCP", uint.MaxValue)] - [InlineData("EXIF", uint.MaxValue)] - [InlineData("XMP ", uint.MaxValue)] + [InlineData("iccp", 0x40000000U)] + [InlineData("exif", 0x40000000U)] + [InlineData("xmp", 0x40000000U)] + [InlineData("iccp", 0xFFFFFFFEU)] + [InlineData("exif", 0xFFFFFFFEU)] + [InlineData("xmp", 0xFFFFFFFEU)] + [InlineData("iccp", uint.MaxValue)] + [InlineData("exif", uint.MaxValue)] + [InlineData("xmp", uint.MaxValue)] public void Decode_WithOversizedMetadataChunk_ThrowsInvalidImageContentException(string chunkType, uint length) { - byte[] payload = Convert.FromHexString( - "524946462200000057454250565038580A0000002000000000000000000049434350FEFFFFFF01020304"); - Encoding.ASCII.GetBytes(chunkType, payload.AsSpan(30, 4)); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(34), length); - DecoderOptions options = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; - - Assert.Throws(() => Image.Load(options, payload)); - Assert.Throws(() => Image.Identify(options, payload)); - } - - [Theory] - [InlineData("ICCP")] - [InlineData("EXIF")] - [InlineData("XMP ")] - public void Decode_WithMetadataChunkLargerThanRemainingData_ThrowsInStrictMode(string chunkType) - { - // The RIFF size covers the whole payload, so the metadata chunk is read rather than ignored as trailing data. - byte[] payload = Convert.FromHexString( - "524946462200000057454250565038580A00000020000000010000010000494343500000004000000000"); - Encoding.ASCII.GetBytes(chunkType, payload.AsSpan(30, 4)); + // The stored RIFF size covers the file. The metadata declaration itself exceeds that boundary. + byte[] payload = TestFile.Create($"{TestImages.Webp.ContainerDirectory}{chunkType}-oversized-{length:x8}.webp").Bytes; DecoderOptions options = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict }; Assert.Throws(() => Image.Load(options, payload)); @@ -299,9 +283,9 @@ public void Decode_WithMetadataChunkLargerThanRemainingData_ThrowsInStrictMode(s [Theory] [MemberData(nameof(TruncatedMetadataOptions))] - public void Decode_TruncatedTrailingMetadata_RespectsOptions(string chunkType, uint length, SegmentIntegrityHandling integrity, bool skipMetadata) + public void Decode_TruncatedTrailingMetadata_RespectsOptions(string fileName, SegmentIntegrityHandling integrity, bool skipMetadata) { - byte[] payload = CreateWebpWithMetadata(chunkType, length, false, false); + byte[] payload = TestFile.Create(fileName).Bytes; DecoderOptions options = new() { SegmentIntegrityHandling = integrity, SkipMetadata = skipMetadata }; if (integrity is SegmentIntegrityHandling.Strict && !skipMetadata) @@ -335,7 +319,7 @@ public void Decode_TruncatedTrailingMetadata_RespectsOptions(string chunkType, u [MemberData(nameof(IccMetadataOptions))] public void Decode_InvalidIccPayload_RespectsOptionsAndReadsFollowingImage(SegmentIntegrityHandling integrity, bool skipMetadata, bool animated) { - byte[] payload = CreateWebpWithMetadata("ICCP", 4, true, animated); + byte[] payload = TestFile.Create(TestImages.Webp.ContainerDirectory + (animated ? "invalid-icc-animated.webp" : "invalid-icc.webp")).Bytes; DecoderOptions options = new() { SegmentIntegrityHandling = integrity, SkipMetadata = skipMetadata }; if (integrity is SegmentIntegrityHandling.Strict && !skipMetadata) @@ -361,7 +345,7 @@ public void Decode_InvalidIccPayload_RespectsOptionsAndReadsFollowingImage(Segme [MemberData(nameof(IccMetadataOptions))] public void Decode_TruncatedIccFraming_RemainsFatal(SegmentIntegrityHandling integrity, bool skipMetadata, bool animated) { - byte[] payload = CreateWebpWithMetadata("ICCP", 0x40000000, true, animated); + byte[] payload = TestFile.Create(TestImages.Webp.ContainerDirectory + (animated ? "truncated-icc-animated.webp" : "truncated-icc.webp")).Bytes; DecoderOptions options = new() { SegmentIntegrityHandling = integrity, SkipMetadata = skipMetadata }; Assert.Throws(() => Image.Load(options, payload)); @@ -373,43 +357,15 @@ public void Decode_TruncatedIccFraming_RemainsFatal(SegmentIntegrityHandling int [InlineData(true)] public void Decode_OddLengthMetadataChunks_ReadsPayloadsWithoutPaddingByte(bool skipMetadata) { - // An ICC profile may have any length its header declares, so one extra byte makes it odd. - byte[] icc = [.. IccTestDataProfiles.ProfileRandomArray, 0x2A]; - BinaryPrimitives.WriteUInt32BigEndian(icc, (uint)icc.Length); - - ExifProfile exifProfile = new(); - exifProfile.SetValue(ExifTag.Software, "ImageSharp"); - byte[] exif = exifProfile.ToByteArray(); - if (exif.Length % 2 == 0) - { - // The EXIF reader ignores bytes after the last directory. - exif = [.. exif, 0x2A]; - } - - byte[] xmp = " "u8.ToArray(); - Assert.True(icc.Length % 2 == 1 && exif.Length % 2 == 1 && xmp.Length % 2 == 1); - - using Image source = new(1, 1, new Rgba32(255, 0, 0)); - using MemoryStream encoded = new(); - source.Save(encoded, new WebpEncoder { FileFormat = WebpFileFormatType.Lossless }); - byte[] simple = encoded.ToArray(); - - // RiffHelper writes the declared odd payload length followed by a zero padding byte. Placing chunks after - // each padded payload also checks that the padding is still skipped before the next chunk header. - using MemoryStream output = new(); - output.Write("RIFF\0\0\0\0WEBP"u8); - RiffHelper.WriteChunk(output, (uint)WebpChunkType.Vp8X, [0x2C, 0, 0, 0, 0, 0, 0, 0, 0, 0]); - RiffHelper.WriteChunk(output, (uint)WebpChunkType.Iccp, icc); - output.Write(simple.AsSpan(12)); - RiffHelper.WriteChunk(output, (uint)WebpChunkType.Exif, exif); - RiffHelper.WriteChunk(output, (uint)WebpChunkType.Xmp, xmp); - byte[] payload = output.ToArray(); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)payload.Length - 8); + // Local libwebp extracts these exact odd-length payloads and decodes the same solid 2x2 image. + byte[] payload = TestFile.Create(TestImages.Webp.ContainerDirectory + "odd-metadata.webp").Bytes; + byte[] icc = TestFile.Create(TestImages.Webp.ContainerDirectory + "odd.icc").Bytes; + byte[] xmp = TestFile.Create(TestImages.Webp.ContainerDirectory + "odd.xmp").Bytes; DecoderOptions options = new() { SkipMetadata = skipMetadata }; using Image image = Image.Load(options, payload); - Assert.Equal(new Rgba32(255, 0, 0), image[0, 0]); + Assert.Equal(new Rgba32(17, 34, 51), image[0, 0]); if (skipMetadata) { Assert.Null(image.Metadata.IccProfile); @@ -425,45 +381,34 @@ public void Decode_OddLengthMetadataChunks_ReadsPayloadsWithoutPaddingByte(bool } /// - /// Places a metadata declaration around a complete lossless image to test recovery independently of pixel truncation. + /// Applies ancillary recovery only within RIFF and keeps incomplete ICC framing fatal. /// - private static byte[] CreateWebpWithMetadata(string chunkType, uint length, bool beforeImage, bool animated) + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Decode_MetadataOutsideRiff_RespectsIntegrity(bool skipMetadata) { - byte[] header = Convert.FromHexString( - "524946460000000057454250565038580A00000020000000010000010000494343500000004000000000"); - header[20] = chunkType switch { "ICCP" => 0x20, "EXIF" => 0x08, _ => 0x04 }; - Encoding.ASCII.GetBytes(chunkType, header.AsSpan(30, 4)); - BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(34), length); - - using Image source = new(2, 2, new Rgba32(17, 34, 51)); - if (animated) - { - header[20] |= 0x02; - using Image secondFrame = new(2, 2, new Rgba32(51, 34, 17)); - source.Frames.AddFrame(secondFrame.Frames.RootFrame); - } - - using MemoryStream encoded = new(); - source.Save(encoded, new WebpEncoder { FileFormat = WebpFileFormatType.Lossless }); - byte[] imageData = encoded.ToArray(); - using MemoryStream combined = new(); - combined.Write(header.AsSpan(0, 30)); - if (beforeImage) + // Complete metadata bytes exist in the physical file, but the RIFF declaration excludes part of them. + byte[] exif = TestFile.Create(TestImages.Webp.ContainerDirectory + "exif-crosses-riff.webp").Bytes; + byte[] icc = TestFile.Create(TestImages.Webp.ContainerDirectory + "icc-crosses-riff.webp").Bytes; + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) { - combined.Write(header.AsSpan(30)); - } + DecoderOptions options = new() { SegmentIntegrityHandling = integrity, SkipMetadata = skipMetadata }; + Assert.Throws(() => Image.Load(options, icc)); + Assert.Throws(() => Image.Identify(options, icc)); - // Replace the encoder's extended header when present, keeping its complete - // image or animation chunks and the deliberately chosen metadata declaration. - int imageChunkOffset = imageData.AsSpan(12, 4).SequenceEqual("VP8X"u8) ? 30 : 12; - combined.Write(imageData.AsSpan(imageChunkOffset)); - if (!beforeImage) - { - combined.Write(header.AsSpan(30)); + if (integrity == SegmentIntegrityHandling.Strict && !skipMetadata) + { + Assert.Throws(() => Image.Load(options, exif)); + Assert.Throws(() => Image.Identify(options, exif)); + } + else + { + using Image image = Image.Load(options, exif); + Assert.Equal(new Rgba32(17, 34, 51), image[0, 0]); + Assert.Null(image.Metadata.ExifProfile); + Assert.Null(Image.Identify(options, exif).Metadata.ExifProfile); + } } - - byte[] payload = combined.ToArray(); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)payload.Length - 8); - return payload; } } diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index b30226ee87..13e31e7079 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -787,6 +787,11 @@ public static class Tga public static class Webp { + /// + /// Stored WebP container boundary and metadata inputs. + /// + public const string ContainerDirectory = "Webp/container/"; + // Reference image as png public const string Peak = "Webp/peak.png"; diff --git a/tests/Images/Input/Webp/container/alpha-crosses-riff.webp b/tests/Images/Input/Webp/container/alpha-crosses-riff.webp new file mode 100644 index 0000000000..9272b608f6 --- /dev/null +++ b/tests/Images/Input/Webp/container/alpha-crosses-riff.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09bc5da7375e05d45229dc26fd26b87b12d37cc4ae64dddbff59991cd0b81edf +size 15592 diff --git a/tests/Images/Input/Webp/container/alpha-trailing.webp b/tests/Images/Input/Webp/container/alpha-trailing.webp new file mode 100644 index 0000000000..82704363c5 --- /dev/null +++ b/tests/Images/Input/Webp/container/alpha-trailing.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0163048b134c9807bb94eddea19466527bff7531ed5e8bc0c70072e365907a62 +size 15633 diff --git a/tests/Images/Input/Webp/container/anim-crosses-riff.webp b/tests/Images/Input/Webp/container/anim-crosses-riff.webp new file mode 100644 index 0000000000..3b5aef03a6 --- /dev/null +++ b/tests/Images/Input/Webp/container/anim-crosses-riff.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:586e82227dbd2765c5d55591f808ee2a845d13c0b011b4581e042bc6c97e9892 +size 144 diff --git a/tests/Images/Input/Webp/container/animation-trailing.webp b/tests/Images/Input/Webp/container/animation-trailing.webp new file mode 100644 index 0000000000..2d154295e9 --- /dev/null +++ b/tests/Images/Input/Webp/container/animation-trailing.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63896b9106a92a7b6975ed1d9a9cd3238e6e18d9a7dc922aa3012f673d8036dc +size 194 diff --git a/tests/Images/Input/Webp/container/animation-unknown.webp b/tests/Images/Input/Webp/container/animation-unknown.webp new file mode 100644 index 0000000000..6038a0891b --- /dev/null +++ b/tests/Images/Input/Webp/container/animation-unknown.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b67e6b478395a9b7c97c8f4449edb6fe6d45bfa5e61fa6c5fa0bfff4902794a +size 160 diff --git a/tests/Images/Input/Webp/container/animation.webp b/tests/Images/Input/Webp/container/animation.webp new file mode 100644 index 0000000000..564c17aa8b --- /dev/null +++ b/tests/Images/Input/Webp/container/animation.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:699a7d5cd43e32a3206f6d62ad86bf45ecdca89d784f10e69a4a7d6b3365cbe1 +size 144 diff --git a/tests/Images/Input/Webp/container/exif-crosses-riff.webp b/tests/Images/Input/Webp/container/exif-crosses-riff.webp new file mode 100644 index 0000000000..3575b55b34 --- /dev/null +++ b/tests/Images/Input/Webp/container/exif-crosses-riff.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ad367b08cc2f4fb42b8f603c17cf632394b093a89ee2e82ff24e452be159a9d +size 102 diff --git a/tests/Images/Input/Webp/container/exif-oversized-40000000.webp b/tests/Images/Input/Webp/container/exif-oversized-40000000.webp new file mode 100644 index 0000000000..59a15d548d --- /dev/null +++ b/tests/Images/Input/Webp/container/exif-oversized-40000000.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:282d1c24ff4f70354fdb34e95e233943b20a15203fd71e5630b5b20ddd92c838 +size 42 diff --git a/tests/Images/Input/Webp/container/exif-oversized-fffffffe.webp b/tests/Images/Input/Webp/container/exif-oversized-fffffffe.webp new file mode 100644 index 0000000000..2fb2e70cdb --- /dev/null +++ b/tests/Images/Input/Webp/container/exif-oversized-fffffffe.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:60f808da4332a002fcde76bb58119698068a733f11b6cdf28ba8e4b19315f42b +size 42 diff --git a/tests/Images/Input/Webp/container/exif-oversized-ffffffff.webp b/tests/Images/Input/Webp/container/exif-oversized-ffffffff.webp new file mode 100644 index 0000000000..6fb8cfa975 --- /dev/null +++ b/tests/Images/Input/Webp/container/exif-oversized-ffffffff.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:012a7d6ed23747d6de1e64235f5e49ba2bfec59fcbd05667292c0447c837713c +size 42 diff --git a/tests/Images/Input/Webp/container/exif-trailing-40000000.webp b/tests/Images/Input/Webp/container/exif-trailing-40000000.webp new file mode 100644 index 0000000000..87c6e48964 --- /dev/null +++ b/tests/Images/Input/Webp/container/exif-trailing-40000000.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08f9002c3aa324c2dfda829815621611fbae1d2b38e747a76f5d8398d4268b46 +size 68 diff --git a/tests/Images/Input/Webp/container/exif-trailing-fffffffe.webp b/tests/Images/Input/Webp/container/exif-trailing-fffffffe.webp new file mode 100644 index 0000000000..2a99e0e438 --- /dev/null +++ b/tests/Images/Input/Webp/container/exif-trailing-fffffffe.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30045f539f8451906877f151df367773173850b989090956ae0f5090921b84bd +size 68 diff --git a/tests/Images/Input/Webp/container/exif-trailing-ffffffff.webp b/tests/Images/Input/Webp/container/exif-trailing-ffffffff.webp new file mode 100644 index 0000000000..c366244e8b --- /dev/null +++ b/tests/Images/Input/Webp/container/exif-trailing-ffffffff.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:924b98ac9d7bd166a71e92674a4f453c05fbf5e7c1836a965771585201c8b3cd +size 68 diff --git a/tests/Images/Input/Webp/container/frame-crosses-anmf.webp b/tests/Images/Input/Webp/container/frame-crosses-anmf.webp new file mode 100644 index 0000000000..67aceba551 --- /dev/null +++ b/tests/Images/Input/Webp/container/frame-crosses-anmf.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b1adde7a5a50f2b553b39edd3dc73b4076665da50fd84a720424385b745d473 +size 144 diff --git a/tests/Images/Input/Webp/container/frame-crosses-riff.webp b/tests/Images/Input/Webp/container/frame-crosses-riff.webp new file mode 100644 index 0000000000..2932cc3833 --- /dev/null +++ b/tests/Images/Input/Webp/container/frame-crosses-riff.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3886b6570081efe99d66194dbc1c8b00013cd6b34dbafa9d8166bf1dd894887 +size 144 diff --git a/tests/Images/Input/Webp/container/icc-crosses-riff.webp b/tests/Images/Input/Webp/container/icc-crosses-riff.webp new file mode 100644 index 0000000000..f3c5f5ca0a --- /dev/null +++ b/tests/Images/Input/Webp/container/icc-crosses-riff.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4e7d8c31fa8d158a0010491ed460a88ff27ab35ed3e117418265e1f40e1a567 +size 3090 diff --git a/tests/Images/Input/Webp/container/iccp-oversized-40000000.webp b/tests/Images/Input/Webp/container/iccp-oversized-40000000.webp new file mode 100644 index 0000000000..2b67366bcc --- /dev/null +++ b/tests/Images/Input/Webp/container/iccp-oversized-40000000.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:adda83ffd21904ab4fd256e9b215e82ea5c80409c74a9ae340611c528e5f7550 +size 42 diff --git a/tests/Images/Input/Webp/container/iccp-oversized-fffffffe.webp b/tests/Images/Input/Webp/container/iccp-oversized-fffffffe.webp new file mode 100644 index 0000000000..6e3b1354df --- /dev/null +++ b/tests/Images/Input/Webp/container/iccp-oversized-fffffffe.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd2fb9d91693cfcd5cf21f074f69d4ee07f6cedebfbe5e3ac90c1f7d22f0a3e1 +size 42 diff --git a/tests/Images/Input/Webp/container/iccp-oversized-ffffffff.webp b/tests/Images/Input/Webp/container/iccp-oversized-ffffffff.webp new file mode 100644 index 0000000000..5c08c01669 --- /dev/null +++ b/tests/Images/Input/Webp/container/iccp-oversized-ffffffff.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce86eade9dd0840645d18e627ea5b373ded14ee73252f78ad6c11d64ccfc99ca +size 42 diff --git a/tests/Images/Input/Webp/container/invalid-icc-animated.webp b/tests/Images/Input/Webp/container/invalid-icc-animated.webp new file mode 100644 index 0000000000..1cd8e52b60 --- /dev/null +++ b/tests/Images/Input/Webp/container/invalid-icc-animated.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20648a2052f5e34278c6e523799454b080ee4a03c5519da532aad751eee49be0 +size 156 diff --git a/tests/Images/Input/Webp/container/invalid-icc.webp b/tests/Images/Input/Webp/container/invalid-icc.webp new file mode 100644 index 0000000000..3b9ad7896c --- /dev/null +++ b/tests/Images/Input/Webp/container/invalid-icc.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:524abc085d8c3785d521e47a6c8fd62090c854de5520e1efa77616876ff4ed89 +size 68 diff --git a/tests/Images/Input/Webp/container/lossless-trailing.webp b/tests/Images/Input/Webp/container/lossless-trailing.webp new file mode 100644 index 0000000000..f8e05cc12b --- /dev/null +++ b/tests/Images/Input/Webp/container/lossless-trailing.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bf778053e281f77608ab1cecee6869c3c2c64543b224bfa49d8b6419d2e6d73 +size 91 diff --git a/tests/Images/Input/Webp/container/lossy-trailing.webp b/tests/Images/Input/Webp/container/lossy-trailing.webp new file mode 100644 index 0000000000..032642ffed --- /dev/null +++ b/tests/Images/Input/Webp/container/lossy-trailing.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:679e570ad89f17f44ea1244639e5ab8fc3b6a7f22ea6df6bd9e34e511b27308f +size 135 diff --git a/tests/Images/Input/Webp/container/metadata-trailing.webp b/tests/Images/Input/Webp/container/metadata-trailing.webp new file mode 100644 index 0000000000..a557b4212e --- /dev/null +++ b/tests/Images/Input/Webp/container/metadata-trailing.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc6c9f374b14b1bfa8ae34a83da89ec5871fa3b7054d490d54eced12dcd82860 +size 3235 diff --git a/tests/Images/Input/Webp/container/odd-metadata.webp b/tests/Images/Input/Webp/container/odd-metadata.webp new file mode 100644 index 0000000000..d314158390 --- /dev/null +++ b/tests/Images/Input/Webp/container/odd-metadata.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7f8a3a085642819682619762fa1c403d319e7f1351ad6b62db78159ad108005 +size 3194 diff --git a/tests/Images/Input/Webp/container/odd.icc b/tests/Images/Input/Webp/container/odd.icc new file mode 100644 index 0000000000..0f4ac43c34 --- /dev/null +++ b/tests/Images/Input/Webp/container/odd.icc @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a3a43e9054afc30b2593d78488e9b6396b1686dcec91bf814c99bf9157988e80 +size 3025 diff --git a/tests/Images/Input/Webp/container/odd.xmp b/tests/Images/Input/Webp/container/odd.xmp new file mode 100644 index 0000000000..aec23f77bb --- /dev/null +++ b/tests/Images/Input/Webp/container/odd.xmp @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Images/Input/Webp/container/reserved-fields.webp b/tests/Images/Input/Webp/container/reserved-fields.webp new file mode 100644 index 0000000000..54440852fd --- /dev/null +++ b/tests/Images/Input/Webp/container/reserved-fields.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:720e9393fee739b956104a4e35eebcaf92cf05d4f75436355b3c8cd4789dfb34 +size 3194 diff --git a/tests/Images/Input/Webp/container/solid.webp b/tests/Images/Input/Webp/container/solid.webp new file mode 100644 index 0000000000..f07d7dcc8d --- /dev/null +++ b/tests/Images/Input/Webp/container/solid.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c1164f1c98ca6114b3bfab14befdf53bfa3f32201769bf929b5c9b684e7ee1f +size 38 diff --git a/tests/Images/Input/Webp/container/truncated-icc-animated.webp b/tests/Images/Input/Webp/container/truncated-icc-animated.webp new file mode 100644 index 0000000000..2e21969d1f --- /dev/null +++ b/tests/Images/Input/Webp/container/truncated-icc-animated.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05ca4851ba80841c52c739cc4617a32dc7653d9bf852afea1ea33f7c416579dc +size 156 diff --git a/tests/Images/Input/Webp/container/truncated-icc.webp b/tests/Images/Input/Webp/container/truncated-icc.webp new file mode 100644 index 0000000000..7eb05a2191 --- /dev/null +++ b/tests/Images/Input/Webp/container/truncated-icc.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f3b86b8917f0d15baf921aca9ba59e10a3d7e18715d25a5558c20315f4e8300 +size 68 diff --git a/tests/Images/Input/Webp/container/unknown-maximum.webp b/tests/Images/Input/Webp/container/unknown-maximum.webp new file mode 100644 index 0000000000..e1ec0b9e2b --- /dev/null +++ b/tests/Images/Input/Webp/container/unknown-maximum.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e60e811eedea648027b3d00ed8dd90af067f321cbef563f34ba5b3ca7511d897 +size 3205 diff --git a/tests/Images/Input/Webp/container/unknown-signed.webp b/tests/Images/Input/Webp/container/unknown-signed.webp new file mode 100644 index 0000000000..71d6e315af --- /dev/null +++ b/tests/Images/Input/Webp/container/unknown-signed.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de36e99d8323af50aefea60e3b27d67643acedc0b3de869dade661b36c96fb1d +size 3205 diff --git a/tests/Images/Input/Webp/container/unknown-unsigned.webp b/tests/Images/Input/Webp/container/unknown-unsigned.webp new file mode 100644 index 0000000000..1a4855b163 --- /dev/null +++ b/tests/Images/Input/Webp/container/unknown-unsigned.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e6e1941a7316d15d7ca0c6e4b61882a4b787fcd0ec8386743dd61d077944760 +size 3205 diff --git a/tests/Images/Input/Webp/container/vp8-crosses-riff.webp b/tests/Images/Input/Webp/container/vp8-crosses-riff.webp new file mode 100644 index 0000000000..753c262004 --- /dev/null +++ b/tests/Images/Input/Webp/container/vp8-crosses-riff.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d7f5da05f1ae366c320cef29c6d0d83179e3c9f065ff897492fb58d9a3f8ea7 +size 94 diff --git a/tests/Images/Input/Webp/container/vp8l-crosses-riff.webp b/tests/Images/Input/Webp/container/vp8l-crosses-riff.webp new file mode 100644 index 0000000000..56d477fd49 --- /dev/null +++ b/tests/Images/Input/Webp/container/vp8l-crosses-riff.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fa289ca9a94d7e50c03ff223105a8eab4b02e00c6eb732153a2200d9b5cbf15 +size 50 diff --git a/tests/Images/Input/Webp/container/vp8l-short-header.webp b/tests/Images/Input/Webp/container/vp8l-short-header.webp new file mode 100644 index 0000000000..ef3ad10bba --- /dev/null +++ b/tests/Images/Input/Webp/container/vp8l-short-header.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aec2a7c26017b39d6f18423b7e15ebdfb96036d5bc2e27df866c1883c7656a99 +size 50 diff --git a/tests/Images/Input/Webp/container/vp8x-crosses-riff.webp b/tests/Images/Input/Webp/container/vp8x-crosses-riff.webp new file mode 100644 index 0000000000..d007296019 --- /dev/null +++ b/tests/Images/Input/Webp/container/vp8x-crosses-riff.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ce585f18873be613f1d9973632d75cbdc770036fcf14bff783201c646fdb65e +size 15592 diff --git a/tests/Images/Input/Webp/container/vp8x-short-header.webp b/tests/Images/Input/Webp/container/vp8x-short-header.webp new file mode 100644 index 0000000000..a964445a7c --- /dev/null +++ b/tests/Images/Input/Webp/container/vp8x-short-header.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3aa4a880d820b65530cc1fc20f5cf6774ac09aeeaf16ce260f48e0bfeec0273d +size 15592 diff --git a/tests/Images/Input/Webp/container/xmp-oversized-40000000.webp b/tests/Images/Input/Webp/container/xmp-oversized-40000000.webp new file mode 100644 index 0000000000..97c45c7ffb --- /dev/null +++ b/tests/Images/Input/Webp/container/xmp-oversized-40000000.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:596b69b4326bac8ec8db5a29b5265de94d239bf3fbe0b0721427e1d546c19167 +size 42 diff --git a/tests/Images/Input/Webp/container/xmp-oversized-fffffffe.webp b/tests/Images/Input/Webp/container/xmp-oversized-fffffffe.webp new file mode 100644 index 0000000000..fc00d2479e --- /dev/null +++ b/tests/Images/Input/Webp/container/xmp-oversized-fffffffe.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a9a98c6e135e9469c4317735437905b7f4aee3c7b00af819245c54f04b08940 +size 42 diff --git a/tests/Images/Input/Webp/container/xmp-oversized-ffffffff.webp b/tests/Images/Input/Webp/container/xmp-oversized-ffffffff.webp new file mode 100644 index 0000000000..a69249a378 --- /dev/null +++ b/tests/Images/Input/Webp/container/xmp-oversized-ffffffff.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac4277b72303c6980f073fc73d02702ec12e6c4edf2a23c1a9639e63a9c70c5b +size 42 diff --git a/tests/Images/Input/Webp/container/xmp-trailing-40000000.webp b/tests/Images/Input/Webp/container/xmp-trailing-40000000.webp new file mode 100644 index 0000000000..bc665d229a --- /dev/null +++ b/tests/Images/Input/Webp/container/xmp-trailing-40000000.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54e2b14a780d8aaa98d17a0126f307443bb1a05f5f333f0e1f91b7332df2fe09 +size 68 diff --git a/tests/Images/Input/Webp/container/xmp-trailing-fffffffe.webp b/tests/Images/Input/Webp/container/xmp-trailing-fffffffe.webp new file mode 100644 index 0000000000..3d28a9fd29 --- /dev/null +++ b/tests/Images/Input/Webp/container/xmp-trailing-fffffffe.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c013fdff7494048bee6c312f2e537a68dc68d859aa6830e73ffdec8f2879fbf +size 68 diff --git a/tests/Images/Input/Webp/container/xmp-trailing-ffffffff.webp b/tests/Images/Input/Webp/container/xmp-trailing-ffffffff.webp new file mode 100644 index 0000000000..f6fed3fee0 --- /dev/null +++ b/tests/Images/Input/Webp/container/xmp-trailing-ffffffff.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44d29ce6553faed51f62e9fa4eb1136f386021deedca5427f96ef5b35911f99d +size 68 From 6515ff0e33634d37810fd8d9aa88b83f410dcce4 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Tue, 27 Oct 2026 16:19:33 +1000 Subject: [PATCH 3/3] Verify WebP animation retains metadata read before ANIM --- src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs | 2 +- src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs | 8 ++++---- src/ImageSharp/Formats/Webp/WebpDecoderCore.cs | 2 ++ .../Formats/WebP/WebpMetaDataTests.cs | 12 ++++++++---- .../Input/Webp/container/odd-metadata-animated.webp | 3 +++ 5 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 tests/Images/Input/Webp/container/odd-metadata-animated.webp diff --git a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs index 86e29a679e..032e8be446 100644 --- a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs +++ b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs @@ -250,7 +250,7 @@ public Image Decode( /// Reads frame information from the specified stream and updates the provided frame metadata. /// /// The stream from which to read the frame information. Must support reading and seeking. - /// A reference to the structure that will be updated with the parsed frame metadata. + /// The metadata updated from the frame header. /// The end of the RIFF payload. private static void ReadFrameInfo(BufferedReadStream stream, ImageFrameMetadata frameMetadata, long containerEnd) { diff --git a/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs b/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs index 402c96542b..5af53068bf 100644 --- a/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs +++ b/src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs @@ -240,19 +240,19 @@ public static WebpImageInfo ReadVp8XHeader(BufferedReadStream stream, Span // but the container specification requires readers to ignore them, so they are not validated. byte imageFeatures = (byte)stream.ReadByte(); - // If bit 3 is set, a ICC Profile Chunk should be present. + // If bit 5 is set, an ICC Profile Chunk should be present. features.IccProfile = (imageFeatures & (1 << 5)) != 0; // If bit 4 is set, any of the frames of the image contain transparency information ("alpha" chunk). features.Alpha = (imageFeatures & (1 << 4)) != 0; - // If bit 5 is set, a EXIF metadata should be present. + // If bit 3 is set, EXIF metadata should be present. features.ExifProfile = (imageFeatures & (1 << 3)) != 0; - // If bit 6 is set, XMP metadata should be present. + // If bit 2 is set, XMP metadata should be present. features.XmpMetaData = (imageFeatures & (1 << 2)) != 0; - // If bit 7 is set, animation should be present. + // If bit 1 is set, animation should be present. features.Animation = (imageFeatures & (1 << 1)) != 0; // Readers ignore these three reserved bytes, as they ignore reserved feature bits. diff --git a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs index fba9c7b83a..0c0d59c7a5 100644 --- a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs +++ b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs @@ -234,6 +234,8 @@ private WebpImageInfo ReadVp8Info(BufferedReadStream stream, ImageMetadata metad WebpImageInfo? info = null; WebpFeatures features = new(); bool allowTruncatedPayload = this.Options.SegmentIntegrityHandling == SegmentIntegrityHandling.IgnoreImageData; + + // Retain local ownership until return. Metadata parsing can fail after a copied bitstream has been allocated. try { switch (chunkType) diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs index 2f05139e64..1cb9890626 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpMetaDataTests.cs @@ -353,18 +353,22 @@ public void Decode_TruncatedIccFraming_RemainsFatal(SegmentIntegrityHandling int } [Theory] - [InlineData(false)] - [InlineData(true)] - public void Decode_OddLengthMetadataChunks_ReadsPayloadsWithoutPaddingByte(bool skipMetadata) + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void Decode_OddLengthMetadataChunks_ReadsPayloadsWithoutPaddingByte(bool skipMetadata, bool animated) { // Local libwebp extracts these exact odd-length payloads and decodes the same solid 2x2 image. - byte[] payload = TestFile.Create(TestImages.Webp.ContainerDirectory + "odd-metadata.webp").Bytes; + byte[] payload = TestFile.Create(TestImages.Webp.ContainerDirectory + (animated ? "odd-metadata-animated.webp" : "odd-metadata.webp")).Bytes; byte[] icc = TestFile.Create(TestImages.Webp.ContainerDirectory + "odd.icc").Bytes; byte[] xmp = TestFile.Create(TestImages.Webp.ContainerDirectory + "odd.xmp").Bytes; DecoderOptions options = new() { SkipMetadata = skipMetadata }; using Image image = Image.Load(options, payload); + // ICCP precedes ANIM. Entering the frame decoder must retain that profile, not replace its metadata owner. + Assert.Equal(animated ? 2 : 1, image.Frames.Count); Assert.Equal(new Rgba32(17, 34, 51), image[0, 0]); if (skipMetadata) { diff --git a/tests/Images/Input/Webp/container/odd-metadata-animated.webp b/tests/Images/Input/Webp/container/odd-metadata-animated.webp new file mode 100644 index 0000000000..de4212eb10 --- /dev/null +++ b/tests/Images/Input/Webp/container/odd-metadata-animated.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:faa7a5b6c049ab763b7728584d9823f25376e4f8fd6969516eb6d6871b5cd2a5 +size 3282