diff --git a/lang/csharp/src/apache/main/Generic/GenericReader.cs b/lang/csharp/src/apache/main/Generic/GenericReader.cs index 0b945b9ff5e..13b3e90fdd4 100644 --- a/lang/csharp/src/apache/main/Generic/GenericReader.cs +++ b/lang/csharp/src/apache/main/Generic/GenericReader.cs @@ -404,11 +404,52 @@ protected virtual object ReadArray(object reuse, ArraySchema writerSchema, Schem ArraySchema rs = (ArraySchema)readerSchema; object result = CreateArray(reuse, rs); int i = 0; - for (int n = (int)d.ReadArrayStart(); n != 0; n = (int)d.ReadArrayNext()) + long minBytes = MinBytesPerElement(writerSchema.ItemSchema); + long total = 0; + for (long nl = d.ReadArrayStart(); nl != 0; nl = d.ReadArrayNext()) { - if (GetArraySize(result) < (i + n)) ResizeArray(ref result, i + n); + // Reject a block whose element count could not be backed by the + // bytes remaining (or, for zero-byte elements, that exceeds the + // item cap) before allocating for it. Checked on the raw long, + // which also avoids the int cast below overflowing. + total = EnsureCollectionAvailable(d, total, nl, minBytes); + int n = (int)nl; + // Preallocate only a bounded amount up front, then grow on demand + // below. On a non-seekable stream EnsureCollectionAvailable cannot + // bound the count, so resizing straight to i+n could allocate a + // huge array before any element is read; a truncated stream instead + // fails within Read() after a bounded growth. Blocks no larger than + // the cap keep the original single-resize fast path. Compute in + // long and clamp so a large i near the structural cap cannot + // overflow the int sum. + long preallocLong = Math.Min((long)i + Math.Min(n, MaxCollectionPrealloc), MaxCollectionStructural); + int prealloc = (int)preallocLong; + if (GetArraySize(result) < prealloc) ResizeArray(ref result, prealloc); for (int j = 0; j < n; j++, i++) { + if (GetArraySize(result) <= i) + { + int current = GetArraySize(result); + // Grow ~1.5x, computed in long to avoid int overflow, and + // clamp to the structural cap (which is <= the runtime's + // max array length). The validated element count never + // exceeds that cap, so clamping cannot starve a legitimate + // collection while it keeps Array.Resize from being handed + // an over-large (or overflowed/negative) size. + long grown = (long)current + (current >> 1) + 1; + if (grown < i + 1) + { + grown = i + 1; + } + + if (grown > MaxCollectionStructural) + { + grown = MaxCollectionStructural; + } + + ResizeArray(ref result, (int)grown); + } + SetArrayElement(result, i, Read(GetArrayElement(result, i), writerSchema.ItemSchema, rs.ItemSchema, d)); } } @@ -490,8 +531,13 @@ protected virtual object ReadMap(object reuse, MapSchema writerSchema, Schema re { MapSchema rs = (MapSchema)readerSchema; object result = CreateMap(reuse, rs); - for (int n = (int)d.ReadMapStart(); n != 0; n = (int)d.ReadMapNext()) + // Map keys are strings (>= 1 byte length prefix) plus the value. + long minBytes = 1L + MinBytesPerElement(writerSchema.ValueSchema); + long total = 0; + for (long nl = d.ReadMapStart(); nl != 0; nl = d.ReadMapNext()) { + total = EnsureCollectionAvailable(d, total, nl, minBytes); + int n = (int)nl; for (int j = 0; j < n; j++) { string k = d.ReadString(); @@ -501,6 +547,166 @@ protected virtual object ReadMap(object reuse, MapSchema writerSchema, Schema re return result; } + /// + /// Minimum number of bytes a single value of the given schema can occupy + /// on the wire. Used to reject an array/map block count that could not be + /// backed by the bytes remaining. A type that encodes to zero bytes + /// returns 0 (not only null, but also composites that encode to + /// nothing, e.g. a record whose fields are all zero-byte), which disables + /// the bytes-remaining check for it (so an array of such elements is not + /// falsely rejected; they are instead bounded by the zero-byte item cap). + /// A depth limit breaks self-referencing schemas. + /// + private static int MinBytesPerElement(Schema schema, int depth = 0) + { + if (schema == null) + { + return 0; + } + + switch (schema.Tag) + { + case Schema.Type.Null: + return 0; + case Schema.Type.Float: + return 4; + case Schema.Type.Double: + return 8; + case Schema.Type.Fixed: + return ((FixedSchema)schema).Size; + case Schema.Type.Record: + case Schema.Type.Error: + if (depth > 64) + { + // A cyclic or pathologically deep record. Return 1 (not + // 0) so the collection check stays enabled; a valid + // recursive value always encodes to >= 1 byte. The depth + // guard is applied only here, so zero-byte leaf types + // such as null still return 0 regardless of depth. + return 1; + } + + // Accumulate in a long and clamp so a deeply nested schema + // cannot overflow int into a value <= 0, which would disable + // the collection check. + long total = 0; + foreach (Field f in (RecordSchema)schema) + { + total += MinBytesPerElement(f.Schema, depth + 1); + if (total >= int.MaxValue) + { + return int.MaxValue; + } + } + + return (int)total; + default: + // boolean, int, long, bytes, string, enum, union, array, map: + // all encode to at least one byte. + return 1; + } + } + + // Collection allocation limits, guarding against a block-count DoS. Both + // default to the same values as the other Avro SDKs and can be overridden + // (to a single value capping both) via the AVRO_MAX_COLLECTION_ITEMS + // environment variable. + private static readonly long MaxCollectionItems = ReadCollectionLimit(10_000_000L); + + // The largest array the runtime can allocate. Mirrors + // BinaryDecoder.MaxDotNetArrayLength: the default reader grows its backing + // array via Array.Resize, which throws (OutOfMemoryException/OverflowException) + // above this length rather than a deterministic AvroException. +#if NETSTANDARD2_0 + private const int MaxDotNetArrayLength = 0x3FFFFFFF; +#else + private const int MaxDotNetArrayLength = 0x7FFFFFC7; +#endif + + // The structural cap is additionally clamped to the runtime's maximum + // array length: the callers cast the (cumulative) block count to int to + // size .NET collections, and a limit above the max array length (e.g. from + // a large env override, or int.MaxValue itself) would let a collection + // that passes EnsureCollectionAvailable still fault inside Array.Resize + // instead of failing deterministically. + private static readonly long MaxCollectionStructural = + Math.Min(ReadCollectionLimit(2147483639L), MaxDotNetArrayLength); + + // Upper bound on how many elements the backing array is grown by in a + // single step while decoding. The array still grows to hold every element + // actually read; this only avoids resizing to the full (possibly + // attacker-declared) block count up front, before any element is read. + // That matters most for non-seekable streams, where the bytes-available + // check cannot bound the declared count, so a single Array.Resize to the + // block count could allocate a huge array before the truncated stream is + // detected. + private const int MaxCollectionPrealloc = 1024; + + private static long ReadCollectionLimit(long defaultValue) + { + string env = Environment.GetEnvironmentVariable("AVRO_MAX_COLLECTION_ITEMS"); + if (!string.IsNullOrEmpty(env) && long.TryParse(env, out long value) && value >= 0) + { + return value; + } + + return defaultValue; + } + + /// + /// Rejects a collection (array or map) block that could drive an unbounded + /// allocation, before allocating for it. A block whose declared element + /// count could not be backed by the bytes actually remaining is rejected; + /// zero-byte element blocks (where the bytes-remaining check does not + /// apply) are bounded by a cumulative item cap; and every collection is + /// bounded by a structural cap. Returns the running total across blocks. + /// + private static long EnsureCollectionAvailable(Decoder d, long total, long count, long minBytesPerElement) + { + // A negative count is corrupt/malicious data (it can also arise from + // long.MinValue overflow when negating a negative block count), and + // the callers cast the block count to int; reject it explicitly. + if (count < 0) + { + throw new AvroException($"Invalid negative collection block count: {count}"); + } + + // Reject before adding so an oversized block count cannot overflow + // `total` (wrapping it negative and bypassing the caps below). The + // running total is always <= MaxCollectionStructural on entry (the + // invariant this method maintains) and count >= 0, so the subtraction + // cannot underflow or overflow. + if (count > MaxCollectionStructural - total) + { + throw new AvroException( + $"Collection size {total} + {count} exceeds the maximum allowed size of {MaxCollectionStructural}"); + } + + total += count; + + if (minBytesPerElement <= 0) + { + // Zero-byte elements (e.g. null) consume no input, so the + // bytes-remaining check cannot bound them; cap by item count. + if (total > MaxCollectionItems) + { + throw new AvroException( + $"Collection of zero-byte elements ({total}) exceeds the maximum allowed size of {MaxCollectionItems}"); + } + } + else if (d is BinaryDecoder bd) + { + long remaining = bd.RemainingBytes(); + if (remaining >= 0 && count > remaining / minBytesPerElement) + { + throw new AvroException( + $"Collection claims {count} elements with at least {minBytesPerElement} bytes each, but only {remaining} bytes are available"); + } + } + + return total; + } + /// /// Used by the default implementation of ReadMap() to create a fresh map object. The default /// implementation of this method returns a IDictionary<string, map>. @@ -661,8 +867,11 @@ protected virtual void Skip(Schema writerSchema, Decoder d) case Schema.Type.Array: { Schema s = (writerSchema as ArraySchema).ItemSchema; + long minBytes = MinBytesPerElement(s); + long total = 0; for (long n = d.ReadArrayStart(); n != 0; n = d.ReadArrayNext()) { + total = EnsureCollectionAvailable(d, total, n, minBytes); for (long i = 0; i < n; i++) Skip(s, d); } } @@ -670,8 +879,11 @@ protected virtual void Skip(Schema writerSchema, Decoder d) case Schema.Type.Map: { Schema s = (writerSchema as MapSchema).ValueSchema; + long minBytes = 1L + MinBytesPerElement(s); + long total = 0; for (long n = d.ReadMapStart(); n != 0; n = d.ReadMapNext()) { + total = EnsureCollectionAvailable(d, total, n, minBytes); for (long i = 0; i < n; i++) { d.SkipString(); Skip(s, d); } } } diff --git a/lang/csharp/src/apache/main/IO/BinaryDecoder.cs b/lang/csharp/src/apache/main/IO/BinaryDecoder.cs index 56aaa6e1815..b876f2ce45e 100644 --- a/lang/csharp/src/apache/main/IO/BinaryDecoder.cs +++ b/lang/csharp/src/apache/main/IO/BinaryDecoder.cs @@ -76,7 +76,22 @@ public long ReadLong() int shift = 7; while ((b & 0x80) != 0) { + // A 64-bit value uses at most 10 bytes (shifts 0..63); reject an + // overlong varint rather than silently wrapping to a wrong value. + if (shift >= 70) + { + throw new AvroException("Varint is too long"); + } + b = read(); + // The 10th byte (shift == 63) contributes only bit 63; any higher + // payload bit (b & 0x7E) would be silently dropped by << 63, so a + // valid encoding must have them clear. Reject otherwise. + if (shift == 63 && (b & 0x7E) != 0) + { + throw new AvroException("Invalid long encoding"); + } + n |= (b & 0x7FUL) << shift; shift += 7; } @@ -264,11 +279,73 @@ public void SkipFixed(int len) // Read p bytes into a new byte buffer private byte[] read(long p) { - byte[] buffer = new byte[p]; + if (p < 0) + { + throw new AvroException($"Can not read a negative number of bytes: {p}"); + } + + if (p > MaxDotNetArrayLength) + { + // A .NET array cannot be larger than this; reject with a + // consistent AvroException rather than letting new byte[p] throw + // an OverflowException/OutOfMemoryException, mirroring the + // maximum-length guard in ReadString() (the message differs). + throw new AvroException($"Length {p} exceeds the maximum supported array length"); + } + + EnsureAvailableBytes(p); + // p has been bounded to <= MaxDotNetArrayLength above, so the cast to + // int (required for array allocation) cannot overflow. + byte[] buffer = new byte[(int)p]; Read(buffer, 0, buffer.Length); return buffer; } + /// + /// When the underlying stream can report its length, verifies that at + /// least bytes remain before the caller + /// allocates a buffer of that size. This guards against an + /// out-of-memory attack from a malicious or truncated input that + /// declares a huge length prefix but carries little actual data. The + /// check is skipped for non-seekable streams, whose remaining length is + /// unknown. + /// + /// Number of bytes about to be read. + internal void EnsureAvailableBytes(long length) + { + if (length > 0) + { + long remaining = RemainingBytes(); + if (remaining >= 0 && length > remaining) + { + throw new AvroException( + $"Cannot read {length} bytes, only {remaining} bytes remaining in the stream"); + } + } + } + + /// + /// Returns the number of bytes still available to read from the + /// underlying stream when it is seekable, or -1 when that count is not + /// known (a non-seekable stream). Used to reject a declared length or a + /// collection block count that exceeds the data actually available + /// before allocating for it. + /// + /// The number of bytes remaining, or -1 if unknown. + public long RemainingBytes() + { + if (!stream.CanSeek) + { + return -1; + } + + // Clamp to 0: if the stream was externally seeked past its end (or + // truncated), Position can exceed Length. Callers should only ever + // see -1 (unknown) or a non-negative count. + long remaining = stream.Length - stream.Position; + return remaining < 0 ? 0 : remaining; + } + private byte read() { int n = stream.ReadByte(); @@ -281,6 +358,14 @@ private long doReadItemCount() long result = ReadLong(); if (result < 0) { + // long.MinValue cannot be negated (it would overflow); reject it + // explicitly rather than propagating a wrapped negative or, under + // checked arithmetic, throwing an OverflowException. + if (result == long.MinValue) + { + throw new AvroException("Invalid negative block count: " + result); + } + ReadLong(); // Consume byte-count if present result = -result; } diff --git a/lang/csharp/src/apache/main/IO/BinaryDecoder.netstandard2.0.cs b/lang/csharp/src/apache/main/IO/BinaryDecoder.netstandard2.0.cs index a37d6fa6c84..2cb25cf5f37 100644 --- a/lang/csharp/src/apache/main/IO/BinaryDecoder.netstandard2.0.cs +++ b/lang/csharp/src/apache/main/IO/BinaryDecoder.netstandard2.0.cs @@ -78,23 +78,31 @@ public double ReadDouble() /// String read from the stream. public string ReadString() { - int length = ReadInt(); + // Read the length as a long: the prefix is an Avro long, so a value + // above int.MaxValue would overflow ReadInt() to a negative int, + // bypass EnsureAvailableBytes and throw a misleading "negative length" + // error. Validate the bounds before casting to int. + long length = ReadLong(); if (length < 0) { throw new AvroException("Can not deserialize a string with negative length!"); } + EnsureAvailableBytes(length); + if (length > MaxDotNetArrayLength) { throw new AvroException("String length is not supported!"); } + int intLength = (int)length; + using (var binaryReader = new BinaryReader(stream, Encoding.UTF8, true)) { - var bytes = binaryReader.ReadBytes(length); + var bytes = binaryReader.ReadBytes(intLength); - if (bytes.Length != length) + if (bytes.Length != intLength) { throw new AvroException("Could not read as many bytes from stream as expected!"); } diff --git a/lang/csharp/src/apache/main/IO/BinaryDecoder.notnetstandard2.0.cs b/lang/csharp/src/apache/main/IO/BinaryDecoder.notnetstandard2.0.cs index c4a0dfaaf31..740de780c16 100644 --- a/lang/csharp/src/apache/main/IO/BinaryDecoder.notnetstandard2.0.cs +++ b/lang/csharp/src/apache/main/IO/BinaryDecoder.notnetstandard2.0.cs @@ -66,22 +66,35 @@ public double ReadDouble() /// String read from the stream. public string ReadString() { - int length = ReadInt(); + // Read the length as a long: the prefix is an Avro long, so a value + // above int.MaxValue would overflow ReadInt() to a negative int, + // bypass EnsureAvailableBytes and throw a misleading "negative length" + // error. Validate the bounds before casting to int. + long length = ReadLong(); if (length < 0) { throw new AvroException("Can not deserialize a string with negative length!"); } - if (length <= MaxFastReadLength) + EnsureAvailableBytes(length); + + if (length > MaxDotNetArrayLength) + { + throw new AvroException("String length is not supported!"); + } + + int intLength = (int)length; + + if (intLength <= MaxFastReadLength) { byte[] bufferArray = null; try { - Span buffer = length <= StackallocThreshold ? - stackalloc byte[length] : - (bufferArray = ArrayPool.Shared.Rent(length)).AsSpan(0, length); + Span buffer = intLength <= StackallocThreshold ? + stackalloc byte[intLength] : + (bufferArray = ArrayPool.Shared.Rent(intLength)).AsSpan(0, intLength); Read(buffer); @@ -97,16 +110,11 @@ public string ReadString() } else { - if (length > MaxDotNetArrayLength) - { - throw new AvroException("String length is not supported!"); - } - using (var binaryReader = new BinaryReader(stream, Encoding.UTF8, true)) { - var bytes = binaryReader.ReadBytes(length); + var bytes = binaryReader.ReadBytes(intLength); - if (bytes.Length != length) + if (bytes.Length != intLength) { throw new AvroException("Could not read as many bytes from stream as expected!"); } diff --git a/lang/csharp/src/apache/main/Schema/EnumSchema.cs b/lang/csharp/src/apache/main/Schema/EnumSchema.cs index 225780310a6..6b3db1a16f9 100644 --- a/lang/csharp/src/apache/main/Schema/EnumSchema.cs +++ b/lang/csharp/src/apache/main/Schema/EnumSchema.cs @@ -231,8 +231,8 @@ public string this[int index] { get { - if (index < Symbols.Count) return Symbols[index]; - throw new AvroException("Enumeration out of range. Must be less than " + Symbols.Count + ", but is " + index); + if (index >= 0 && index < Symbols.Count) return Symbols[index]; + throw new AvroException("Enumeration out of range. Must be in [0, " + Symbols.Count + "), but is " + index); } } diff --git a/lang/csharp/src/apache/main/Schema/UnionSchema.cs b/lang/csharp/src/apache/main/Schema/UnionSchema.cs index af9ba758363..3f48d5c88ab 100644 --- a/lang/csharp/src/apache/main/Schema/UnionSchema.cs +++ b/lang/csharp/src/apache/main/Schema/UnionSchema.cs @@ -100,6 +100,12 @@ public Schema this[int index] { get { + if (index < 0 || index >= Schemas.Count) + { + throw new AvroException( + "Union branch index out of range. Must be in [0, " + Schemas.Count + "), but is " + index); + } + return Schemas[index]; } } diff --git a/lang/csharp/src/apache/test/IO/BinaryCodecTests.cs b/lang/csharp/src/apache/test/IO/BinaryCodecTests.cs index a638b73fea2..2b210fc4789 100644 --- a/lang/csharp/src/apache/test/IO/BinaryCodecTests.cs +++ b/lang/csharp/src/apache/test/IO/BinaryCodecTests.cs @@ -22,6 +22,7 @@ using System.Linq; using System.Text; using Avro.IO; +using Avro.Generic; namespace Avro.Test { @@ -38,6 +39,12 @@ namespace Avro.Test [TestFixture] public class BinaryCodecTests { + // NOTE: the collection-limit tests assume the default caps. GenericReader + // captures AVRO_MAX_COLLECTION_ITEMS into static readonly fields at class + // load, so the value is fixed for the process; these tests therefore + // assume the test process was not started with a custom + // AVRO_MAX_COLLECTION_ITEMS (the normal case). Clearing it at runtime + // would have no effect on the already-captured limits. /// /// Writes an avro type T with value t into a stream using the encode method e @@ -278,10 +285,12 @@ public void TestInvalidInputWithMaxIntAsStringLength() iostr.Position = 0; Decoder d = new BinaryDecoder(iostr); + // The declared length far exceeds the bytes remaining in the + // (seekable) stream, so it is rejected before any allocation. var exception = Assert.Throws(() => d.ReadString()); Assert.NotNull(exception); - Assert.AreEqual("String length is not supported!", exception.Message); + StringAssert.Contains("bytes remaining in the stream", exception.Message); iostr.Close(); } } @@ -306,10 +315,12 @@ public void TestInvalidInputWithMaxArrayLengthAsStringLength() iostr.Position = 0; Decoder d = new BinaryDecoder(iostr); + // The declared length far exceeds the bytes remaining in the + // (seekable) stream, so it is rejected before any allocation. var exception = Assert.Throws(() => d.ReadString()); Assert.NotNull(exception); - Assert.AreEqual("Could not read as many bytes from stream as expected!", exception.Message); + StringAssert.Contains("bytes remaining in the stream", exception.Message); iostr.Close(); } } @@ -430,5 +441,330 @@ public void TestFixed(int size) TestSkip(b, (Decoder d) => d.SkipFixed(size), (Encoder e, byte[] t) => e.WriteFixed(t), size); } + + // A bytes/string value is a length prefix followed by that many bytes. + // A malicious or truncated input can declare a huge length with little + // actual data; on a seekable stream the reader must reject it before + // allocating, rather than attempting a huge allocation. + [Test] + public void TestReadBytesRejectsLengthBeyondStream() + { + MemoryStream ms = new MemoryStream(); + Encoder e = new BinaryEncoder(ms); + e.WriteLong(1_000_000); // declares 1,000,000 bytes... + ms.Position = 0; // ...but no data follows + Decoder d = new BinaryDecoder(ms); + Assert.Throws(() => d.ReadBytes()); + } + + [Test] + public void TestReadStringRejectsLengthBeyondStream() + { + MemoryStream ms = new MemoryStream(); + Encoder e = new BinaryEncoder(ms); + e.WriteLong(1_000_000); // declares 1,000,000 bytes... + ms.Position = 0; // ...but no data follows + Decoder d = new BinaryDecoder(ms); + Assert.Throws(() => d.ReadString()); + } + + // A well-formed value whose declared length fits the stream still reads. + [Test] + public void TestReadBytesWithinStreamStillReads() + { + byte[] payload = Encoding.UTF8.GetBytes("hello"); + MemoryStream ms = new MemoryStream(); + Encoder e = new BinaryEncoder(ms); + e.WriteBytes(payload); + ms.Position = 0; + Decoder d = new BinaryDecoder(ms); + Assert.AreEqual(payload, d.ReadBytes()); + } + + // On a non-seekable stream the remaining length is unknown, so the + // pre-check is skipped and a valid value still decodes. + [Test] + public void TestReadBytesNonSeekableStreamStillReads() + { + byte[] payload = Encoding.UTF8.GetBytes("hello"); + MemoryStream backing = new MemoryStream(); + Encoder e = new BinaryEncoder(backing); + e.WriteBytes(payload); + byte[] encoded = backing.ToArray(); + + using (var ns = new NonSeekableStream(new MemoryStream(encoded))) + { + Decoder d = new BinaryDecoder(ns); + Assert.AreEqual(payload, d.ReadBytes()); + } + } + + // An array/map block declares an element count; a malicious or truncated + // input can declare far more elements than the remaining bytes could + // hold. The count is validated against the bytes remaining before + // allocating, using the minimum on-wire size of the element schema (so + // 0-byte elements like null are not falsely rejected). + [Test] + public void TestReadArrayRejectsCountBeyondStream() + { + var schema = Avro.Schema.Parse("{\"type\":\"array\",\"items\":\"long\"}"); + var ms = new MemoryStream(); + new BinaryEncoder(ms).WriteLong(1000000); // 1,000,000 longs, no data + ms.Position = 0; + var reader = new GenericReader(schema, schema); + Assert.Throws(() => reader.Read(null, new BinaryDecoder(ms))); + } + + [Test] + public void TestReadMapRejectsCountBeyondStream() + { + var schema = Avro.Schema.Parse("{\"type\":\"map\",\"values\":\"long\"}"); + var ms = new MemoryStream(); + new BinaryEncoder(ms).WriteLong(1000000); + ms.Position = 0; + var reader = new GenericReader(schema, schema); + Assert.Throws(() => reader.Read(null, new BinaryDecoder(ms))); + } + + [Test] + public void TestReadArrayOfNullNotFalselyRejected() + { + var schema = Avro.Schema.Parse("{\"type\":\"array\",\"items\":\"null\"}"); + var ms = new MemoryStream(); + var enc = new BinaryEncoder(ms); + enc.WriteLong(100000); // one block of 100,000 nulls (zero bytes each) + enc.WriteLong(0); // end-of-array marker + ms.Position = 0; + var reader = new GenericReader(schema, schema); + var result = (Array)reader.Read(null, new BinaryDecoder(ms)); + Assert.AreEqual(100000, result.Length); + } + + // A negative bytes length must be rejected explicitly rather than + // flowing into a negative array allocation. + [Test] + public void TestReadBytesRejectsNegativeLength() + { + var ms = new MemoryStream(); + new BinaryEncoder(ms).WriteLong(-5); + ms.Position = 0; + var d = new BinaryDecoder(ms); + Assert.Throws(() => d.ReadBytes()); + } + + // A block count larger than int.MaxValue must be rejected before the + // int cast, even for a null-element array where the byte check is + // skipped. + [Test] + public void TestReadArrayRejectsCountAboveIntMax() + { + var schema = Avro.Schema.Parse("{\"type\":\"array\",\"items\":\"null\"}"); + var ms = new MemoryStream(); + new BinaryEncoder(ms).WriteLong((long)int.MaxValue + 1); + ms.Position = 0; + var reader = new GenericReader(schema, schema); + Assert.Throws(() => reader.Read(null, new BinaryDecoder(ms))); + } + + // A bytes length above the maximum .NET array length must be rejected + // with a consistent AvroException rather than letting new byte[p] throw. + // A non-seekable stream is used so the length reaches the array-length + // check without needing gigabytes of backing data. + [Test] + public void TestReadBytesRejectsLengthAboveMaxArrayLength() + { + var backing = new MemoryStream(); + new BinaryEncoder(backing).WriteLong(3_000_000_000L); // > any MaxDotNetArrayLength + byte[] encoded = backing.ToArray(); + using (var ns = new NonSeekableStream(new MemoryStream(encoded))) + { + var d = new BinaryDecoder(ns); + Assert.Throws(() => d.ReadBytes()); + } + } + + // A zero-byte element type (null) consumes no input, so the + // bytes-remaining check cannot bound its block count; a tiny payload + // declaring a huge count must instead be rejected by the item cap + // before building the array. + [Test] + public void TestReadArrayOfNullRejectsHugeCount() + { + var schema = Avro.Schema.Parse("{\"type\":\"array\",\"items\":\"null\"}"); + var ms = new MemoryStream(); + new BinaryEncoder(ms).WriteLong(10_000_001); // just over the zero-byte item cap; bounded if the guard regresses + ms.Position = 0; + var reader = new GenericReader(schema, schema); + Assert.Throws(() => reader.Read(null, new BinaryDecoder(ms))); + } + + // A non-zero-byte array on a non-seekable stream cannot have its block + // count bounded by the bytes remaining (the length is unknown). The + // backing array must therefore be grown on demand rather than + // preallocated to the declared count, so a huge count with truncated data + // fails with a bounded AvroException instead of attempting a multi- + // gigabyte allocation. + [Test] + public void TestReadArrayHugeCountOnStreamClampsPreallocation() + { + var schema = Avro.Schema.Parse("{\"type\":\"array\",\"items\":\"long\"}"); + var backing = new MemoryStream(); + new BinaryEncoder(backing).WriteLong(200_000_000); // block count; no element data + byte[] encoded = backing.ToArray(); + using (var ns = new NonSeekableStream(new MemoryStream(encoded))) + { + var reader = new GenericReader(schema, schema); + Assert.Throws(() => reader.Read(null, new BinaryDecoder(ns))); + } + } + + // The skip path (a writer field absent from the reader schema) must be + // bounded too, so skipping a huge zero-byte block cannot loop endlessly. + [Test] + public void TestSkipArrayOfNullRejectsHugeCount() + { + var writer = Avro.Schema.Parse( + "{\"type\":\"record\",\"name\":\"Foo\",\"fields\":[" + + "{\"name\":\"arr\",\"type\":{\"type\":\"array\",\"items\":\"null\"}}," + + "{\"name\":\"val\",\"type\":\"int\"}]}"); + var reader = Avro.Schema.Parse( + "{\"type\":\"record\",\"name\":\"Foo\",\"fields\":[" + + "{\"name\":\"val\",\"type\":\"int\"}]}"); + var ms = new MemoryStream(); + new BinaryEncoder(ms).WriteLong(10_000_001); // just over the zero-byte item cap; bounded if the guard regresses + ms.Position = 0; + var r = new GenericReader(writer, reader); + Assert.Throws(() => r.Read(null, new BinaryDecoder(ms))); + } + + // A collection split across blocks must not let the running total + // overflow: block 1 count=1 then block 2 count=long.MaxValue would wrap + // the total negative and bypass the caps. A non-seekable stream disables + // the bytes-remaining check, so the structural pre-add check must reject it. + [Test] + public void TestReadArrayRejectsBlockCountOverflow() + { + var backing = new MemoryStream(); + var enc = new BinaryEncoder(backing); + enc.WriteLong(1); // block 1: one element + enc.WriteLong(42); // the element + enc.WriteLong(long.MaxValue); // block 2: huge count -> would overflow total + byte[] encoded = backing.ToArray(); + using (var ns = new NonSeekableStream(new MemoryStream(encoded))) + { + var schema = Avro.Schema.Parse("{\"type\":\"array\",\"items\":\"long\"}"); + var reader = new GenericReader(schema, schema); + Assert.Throws(() => reader.Read(null, new BinaryDecoder(ns))); + } + } + + // A block count of long.MinValue cannot be negated; it must be rejected + // rather than wrapping back to a negative (or huge) count. + [Test] + public void TestReadArrayRejectsInt64MinBlockCount() + { + // long.MinValue zig-zag encodes as the 10-byte varint below. + var data = new byte[] { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01 }; + var d = new BinaryDecoder(new MemoryStream(data)); + Assert.Throws(() => d.ReadArrayStart()); + } + + // A union branch index outside [0, branch count) must be rejected with a + // clear AvroException rather than an ArgumentOutOfRangeException. + [Test] + public void TestReadUnionRejectsOutOfRangeIndex() + { + var schema = Avro.Schema.Parse("[\"null\",\"long\"]"); + // Branch index 5 (zig-zag long 0x0a) and -1 (0x01); only 2 branches exist. + foreach (var data in new[] { new byte[] { 0x0a }, new byte[] { 0x01 } }) + { + var reader = new GenericReader(schema, schema); + Assert.Throws(() => reader.Read(null, new BinaryDecoder(new MemoryStream(data)))); + } + } + + // An enum symbol index outside [0, symbol count) must be rejected with a + // clear AvroException. + [Test] + public void TestReadEnumRejectsOutOfRangeIndex() + { + var schema = Avro.Schema.Parse("{\"type\":\"enum\",\"name\":\"E\",\"symbols\":[\"A\",\"B\"]}"); + // Symbol index 9 (zig-zag int 0x12) and -1 (0x01); only 2 symbols exist. + foreach (var data in new[] { new byte[] { 0x12 }, new byte[] { 0x01 } }) + { + var reader = new GenericReader(schema, schema); + Assert.Throws(() => reader.Read(null, new BinaryDecoder(new MemoryStream(data)))); + } + } + + [Test] + public void TestReadMapRejectsInt64MinBlockCount() + { + var data = new byte[] { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01 }; + var d = new BinaryDecoder(new MemoryStream(data)); + Assert.Throws(() => d.ReadMapStart()); + } + + // A 64-bit value uses at most 10 bytes; an 11th continuation byte is a + // malformed (overlong) varint and must be rejected rather than silently + // wrapping to a wrong value. + [Test] + public void TestReadLongRejectsOverlongVarint() + { + var data = new byte[11]; + for (int i = 0; i < 10; i++) data[i] = 0x80; // 10 continuation bytes + data[10] = 0x01; + var d = new BinaryDecoder(new MemoryStream(data)); + Assert.Throws(() => d.ReadLong()); + } + + // A string length prefix above int.MaxValue must be rejected as an + // unsupported length, not overflow the int cast into a negative length. + // A non-seekable stream is used so the check is reached without the + // remaining-bytes guard firing first. + [Test] + public void TestReadStringRejectsLengthAboveInt32() + { + var backing = new MemoryStream(); + new BinaryEncoder(backing).WriteLong((long)int.MaxValue + 1); + byte[] encoded = backing.ToArray(); + using (var ns = new NonSeekableStream(new MemoryStream(encoded))) + { + var d = new BinaryDecoder(ns); + var ex = Assert.Throws(() => d.ReadString()); + StringAssert.Contains("not supported", ex.Message); + } + } + + // Minimal read-only, forward-only stream wrapper reporting CanSeek=false. + private sealed class NonSeekableStream : Stream + { + private readonly Stream inner; + public NonSeekableStream(Stream inner) { this.inner = inner; } + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + inner.Dispose(); + } + + base.Dispose(disposing); + } + } } }