Skip to content

Commit 8fa2067

Browse files
iemejiaRyanSkraba
authored andcommitted
AVRO-4241: [Java] Bound zero-byte collection elements per datum, not per collection (apache#3927)
* AVRO-4241: [Java] Bound zero-byte collection elements per datum, not per collection The heap-aware zero-byte-element allocation cap (null, a zero-length fixed, an all-zero-byte record, or a recursive schema broken with a 0 minimum) was enforced per collection: readArray/readCollection and the skip/fast-reader paths each started counting from zero. Because a container file carries its own schema, an attacker can declare a record with many array<null> fields, each block individually under the limit but jointly unbounded, so a tiny payload still drives a huge aggregate allocation (e.g. ~16 array<null> fields near the per-array cap exhaust the heap; a handful burn tens of seconds of CPU). Track the cumulative zero-byte allocation per decode on a per-thread scope in SystemLimitException. GenericDatumReader.read and the static skip open the scope (scopes nest, so a delegated fast reader or a skipped writer field accumulates into the enclosing datum budget instead of resetting it); only the outermost scope resets the running total. All zero-byte call sites (GenericDatumReader read/skip, FastReaderBuilder, ReflectDatumReader) now use the cumulative checkMaxCollectionAllocation(long). Outside any scope the check falls back to the previous per-collection behaviour, so no existing caller becomes stricter. Positive-size elements are unchanged: they remain bounded per collection by the bytes-remaining check, which consumes input as it advances. Adds regression tests for a multi-field record rejected cumulatively and a within-limit record that still decodes (and confirms the budget resets between datums), on both the fast and classic reader paths. * AVRO-4241: [Java] Scope fast array reader so zero-byte cap is cumulative standalone Open a collection-allocation scope around the fast array reader's block-reading loop in a try/finally. When the fast reader is used standalone via createDatumReader(...), without GenericDatumReader.read opening the outer datum scope, the zero-byte element cap is now cumulative across all array blocks instead of degrading to a per-block stateless check, so a large array<null>-style array split across many blocks cannot bypass the cap. The scope nests into the outer datum scope on the normal path, and the finally guarantees it is always closed so ThreadLocal state cannot leak into later decodes.
1 parent e39580d commit 8fa2067

5 files changed

Lines changed: 244 additions & 65 deletions

File tree

lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,24 @@ public class SystemLimitException extends AvroRuntimeException {
119119
*/
120120
private static long maxCollectionAllocation = defaultMaxCollectionAllocation();
121121

122+
/**
123+
* Per-thread cumulative accounting of zero-byte collection elements allocated
124+
* while decoding a single datum. The {@link #maxCollectionAllocation} cap on
125+
* such elements must apply across the whole datum, not per collection: a
126+
* container file carries its own schema, so an attacker can declare a record
127+
* with many collection fields, each block individually under the limit but
128+
* jointly unbounded. A depth counter marks the outermost decode scope so the
129+
* running total is reset only there and accumulates across every (possibly
130+
* nested) collection in between.
131+
*/
132+
private static final class CollectionAllocationScope {
133+
private int depth;
134+
private long allocated;
135+
}
136+
137+
private static final ThreadLocal<CollectionAllocationScope> COLLECTION_ALLOCATION_SCOPE = ThreadLocal
138+
.withInitial(CollectionAllocationScope::new);
139+
122140
static {
123141
resetLimits();
124142
}
@@ -334,6 +352,68 @@ public static long checkMaxCollectionAllocation(long existing, long items) {
334352
return total;
335353
}
336354

355+
/**
356+
* Begin an outermost decode scope for cumulative zero-byte collection
357+
* allocation accounting. Must be paired with
358+
* {@link #endCollectionAllocationScope()} in a {@code finally} block. Scopes
359+
* nest: only the outermost one resets the running total, so the cap applies
360+
* across the whole datum rather than per collection. See
361+
* {@link #checkMaxCollectionAllocation(long)}.
362+
*/
363+
public static void beginCollectionAllocationScope() {
364+
CollectionAllocationScope scope = COLLECTION_ALLOCATION_SCOPE.get();
365+
if (scope.depth == 0) {
366+
scope.allocated = 0;
367+
}
368+
scope.depth++;
369+
}
370+
371+
/**
372+
* End a decode scope opened by {@link #beginCollectionAllocationScope()}. When
373+
* the outermost scope closes the running total is cleared so it never leaks
374+
* into an unrelated later decode on the same thread.
375+
*/
376+
public static void endCollectionAllocationScope() {
377+
CollectionAllocationScope scope = COLLECTION_ALLOCATION_SCOPE.get();
378+
if (scope.depth > 0) {
379+
scope.depth--;
380+
if (scope.depth == 0) {
381+
scope.allocated = 0;
382+
}
383+
}
384+
}
385+
386+
/**
387+
* Accumulate {@code items} zero-byte-minimum collection elements into the
388+
* current decode scope and verify the running total stays within
389+
* {@link #MAX_COLLECTION_ALLOCATION_PROPERTY the allocation limit}.
390+
* <p>
391+
* Unlike {@link #checkMaxCollectionAllocation(long, long)}, which bounds a
392+
* single collection, this bounds the cumulative count across every collection
393+
* decoded within the enclosing {@link #beginCollectionAllocationScope() scope}
394+
* (one datum), so a record made of many small zero-byte collection fields
395+
* cannot bypass the cap in aggregate. When called outside any scope it falls
396+
* back to a stateless single-collection check, preserving the previous
397+
* behaviour for callers that do not delimit a datum.
398+
*
399+
* @param items The next number of zero-byte elements to allocate.
400+
* @return The cumulative element count if and only if it is within the limit.
401+
* @throws SystemLimitException if the cumulative allocation would exceed the
402+
* limit.
403+
* @throws AvroRuntimeException if {@code items} is negative.
404+
*/
405+
public static long checkMaxCollectionAllocation(long items) {
406+
CollectionAllocationScope scope = COLLECTION_ALLOCATION_SCOPE.get();
407+
if (scope.depth == 0) {
408+
// Not inside a delimited datum: behave as a per-collection check so this
409+
// path is never stricter than before for callers that do not open a scope.
410+
return checkMaxCollectionAllocation(0L, items);
411+
}
412+
long total = checkMaxCollectionAllocation(scope.allocated, items);
413+
scope.allocated = total;
414+
return total;
415+
}
416+
337417
/**
338418
* Check to ensure that reading the string size is within the specified limits.
339419
*

lang/java/avro/src/main/java/org/apache/avro/generic/GenericDatumReader.java

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -168,18 +168,29 @@ protected final ResolvingDecoder getResolver(Schema actual, Schema expected) thr
168168
@Override
169169
@SuppressWarnings("unchecked")
170170
public D read(D reuse, Decoder in) throws IOException {
171-
if (data.isFastReaderEnabled()) {
172-
if (this.fastDatumReader == null) {
173-
this.fastDatumReader = data.getFastReaderBuilder().createDatumReader(actual, expected);
171+
// Open a decode scope so the zero-byte collection-element allocation cap is
172+
// enforced cumulatively across this datum (see SystemLimitException): a
173+
// record with many small array<null>-style fields, each individually under
174+
// the limit, must not be able to over-allocate in aggregate. Nested scopes
175+
// (e.g. the delegated fast reader, or skipped writer fields) accumulate into
176+
// this one; only the outermost resets the running total.
177+
SystemLimitException.beginCollectionAllocationScope();
178+
try {
179+
if (data.isFastReaderEnabled()) {
180+
if (this.fastDatumReader == null) {
181+
this.fastDatumReader = data.getFastReaderBuilder().createDatumReader(actual, expected);
182+
}
183+
return fastDatumReader.read(reuse, in);
174184
}
175-
return fastDatumReader.read(reuse, in);
176-
}
177185

178-
ResolvingDecoder resolver = getResolver(actual, expected);
179-
resolver.configure(in);
180-
D result = (D) read(reuse, expected, resolver);
181-
resolver.drain();
182-
return result;
186+
ResolvingDecoder resolver = getResolver(actual, expected);
187+
resolver.configure(in);
188+
D result = (D) read(reuse, expected, resolver);
189+
resolver.drain();
190+
return result;
191+
} finally {
192+
SystemLimitException.endCollectionAllocationScope();
193+
}
183194
}
184195

185196
/** Called to read data. */
@@ -326,7 +337,7 @@ protected Object readArray(Object old, Schema expected, ResolvingDecoder in) thr
326337
// backing-array allocation.
327338
boolean zeroByteElements = isZeroByteSchema(expectedType);
328339
if (zeroByteElements) {
329-
SystemLimitException.checkMaxCollectionAllocation(base, l);
340+
SystemLimitException.checkMaxCollectionAllocation(l);
330341
}
331342
LogicalType logicalType = expectedType.getLogicalType();
332343
Conversion<?> conversion = getData().getConversionFor(logicalType);
@@ -345,7 +356,7 @@ protected Object readArray(Object old, Schema expected, ResolvingDecoder in) thr
345356
base += l;
346357
l = arrayNext(in, expectedType);
347358
if (zeroByteElements && l > 0) {
348-
SystemLimitException.checkMaxCollectionAllocation(base, l);
359+
SystemLimitException.checkMaxCollectionAllocation(l);
349360
}
350361
} while (l > 0);
351362
return pruneArray(array);
@@ -792,10 +803,24 @@ protected Object createBytes(byte[] value) {
792803

793804
/** Skip an instance of a schema. */
794805
public static void skip(Schema schema, Decoder in) throws IOException {
806+
// Delimit a decode scope so a huge count of zero-byte elements split across
807+
// fields/blocks is bounded cumulatively (see SystemLimitException). Scopes
808+
// nest, so a skip invoked mid-read (e.g. an unused writer field) accumulates
809+
// into the enclosing datum budget instead of resetting it, while a top-level
810+
// skip (e.g. from BinaryData.compare) is bounded per invocation.
811+
SystemLimitException.beginCollectionAllocationScope();
812+
try {
813+
skipInternal(schema, in);
814+
} finally {
815+
SystemLimitException.endCollectionAllocationScope();
816+
}
817+
}
818+
819+
private static void skipInternal(Schema schema, Decoder in) throws IOException {
795820
switch (schema.getType()) {
796821
case RECORD:
797822
for (Field field : schema.getFields())
798-
skip(field.schema(), in);
823+
skipInternal(field.schema(), in);
799824
break;
800825
case ENUM:
801826
in.readEnum();
@@ -816,11 +841,11 @@ public static void skip(Schema schema, Decoder in) throws IOException {
816841
// cannot drive an unbounded skip loop.
817842
SystemLimitException.checkMaxCollectionLength(arrayTotal, l);
818843
if (zeroByteElements) {
819-
SystemLimitException.checkMaxCollectionAllocation(arrayTotal, l);
844+
SystemLimitException.checkMaxCollectionAllocation(l);
820845
}
821846
arrayTotal += l;
822847
for (long i = 0; i < l; i++) {
823-
skip(elementType, in);
848+
skipInternal(elementType, in);
824849
}
825850
}
826851
break;
@@ -833,12 +858,12 @@ public static void skip(Schema schema, Decoder in) throws IOException {
833858
mapTotal += l;
834859
for (long i = 0; i < l; i++) {
835860
in.skipString();
836-
skip(value, in);
861+
skipInternal(value, in);
837862
}
838863
}
839864
break;
840865
case UNION:
841-
skip(schema.getTypes().get(in.readIndex()), in);
866+
skipInternal(schema.getTypes().get(in.readIndex()), in);
842867
break;
843868
case FIXED:
844869
in.skipFixed(schema.getFixedSize());

lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -478,38 +478,48 @@ private FieldReader createArrayReader(Schema readerSchema, Container action) thr
478478
boolean zeroByteElements = GenericDatumReader.isZeroByteSchema(elementType);
479479

480480
return reusingReader((reuse, decoder) -> {
481-
if (reuse instanceof GenericArray) {
482-
GenericArray<Object> reuseArray = (GenericArray<Object>) reuse;
483-
long l = decoder.readArrayStart();
484-
long total = 0;
485-
checkArrayBlock(decoder, elementType, zeroByteElements, total, l);
486-
reuseArray.clear();
487-
488-
while (l > 0) {
489-
for (long i = 0; i < l; i++) {
490-
reuseArray.add(elementReader.read(reuseArray.peek(), decoder));
481+
// Open a decode scope so the zero-byte element allocation cap is cumulative
482+
// across every block of this array even when the fast reader is used
483+
// standalone (i.e. without GenericDatumReader.read opening the outer datum
484+
// scope); otherwise a huge array split into many small blocks would bypass
485+
// the cap. The scope nests: when a datum scope is already open this simply
486+
// accumulates into it, and only the outermost scope resets the running
487+
// total (see SystemLimitException). The try/finally guarantees the scope is
488+
// always closed so ThreadLocal state cannot leak into later decodes on the
489+
// same thread.
490+
SystemLimitException.beginCollectionAllocationScope();
491+
try {
492+
if (reuse instanceof GenericArray) {
493+
GenericArray<Object> reuseArray = (GenericArray<Object>) reuse;
494+
long l = decoder.readArrayStart();
495+
checkArrayBlock(decoder, elementType, zeroByteElements, l);
496+
reuseArray.clear();
497+
498+
while (l > 0) {
499+
for (long i = 0; i < l; i++) {
500+
reuseArray.add(elementReader.read(reuseArray.peek(), decoder));
501+
}
502+
l = decoder.arrayNext();
503+
checkArrayBlock(decoder, elementType, zeroByteElements, l);
491504
}
492-
total += l;
493-
l = decoder.arrayNext();
494-
checkArrayBlock(decoder, elementType, zeroByteElements, total, l);
495-
}
496-
return reuseArray;
497-
} else {
498-
long l = decoder.readArrayStart();
499-
long total = 0;
500-
checkArrayBlock(decoder, elementType, zeroByteElements, total, l);
501-
List<Object> array = (reuse instanceof List) ? (List<Object>) reuse
502-
: new GenericData.Array<>(GenericDatumReader.initialCollectionCapacity(l), readerSchema);
503-
array.clear();
504-
while (l > 0) {
505-
for (long i = 0; i < l; i++) {
506-
array.add(elementReader.read(null, decoder));
505+
return reuseArray;
506+
} else {
507+
long l = decoder.readArrayStart();
508+
checkArrayBlock(decoder, elementType, zeroByteElements, l);
509+
List<Object> array = (reuse instanceof List) ? (List<Object>) reuse
510+
: new GenericData.Array<>(GenericDatumReader.initialCollectionCapacity(l), readerSchema);
511+
array.clear();
512+
while (l > 0) {
513+
for (long i = 0; i < l; i++) {
514+
array.add(elementReader.read(null, decoder));
515+
}
516+
l = decoder.arrayNext();
517+
checkArrayBlock(decoder, elementType, zeroByteElements, l);
507518
}
508-
total += l;
509-
l = decoder.arrayNext();
510-
checkArrayBlock(decoder, elementType, zeroByteElements, total, l);
519+
return array;
511520
}
512-
return array;
521+
} finally {
522+
SystemLimitException.endCollectionAllocationScope();
513523
}
514524
});
515525
}
@@ -521,16 +531,18 @@ private FieldReader createArrayReader(Schema readerSchema, Container action) thr
521531
* heap-aware allocation cap for zero-byte elements (which the bytes check
522532
* cannot bound).
523533
*/
524-
private static void checkArrayBlock(Decoder decoder, Schema elementType, boolean zeroByteElements, long total,
525-
long count) throws IOException {
534+
private static void checkArrayBlock(Decoder decoder, Schema elementType, boolean zeroByteElements, long count)
535+
throws IOException {
526536
if (count <= 0) {
527537
return;
528538
}
529539
if (zeroByteElements) {
530540
// The bytes-remaining check cannot bound zero-byte elements (minBytes is
531541
// 0, so ensureAvailableCollectionBytes would no-op after recomputing it);
532-
// apply the heap-aware allocation cap instead.
533-
SystemLimitException.checkMaxCollectionAllocation(total, count);
542+
// apply the heap-aware allocation cap instead. The cap is cumulative across
543+
// the enclosing datum scope (see SystemLimitException), so a record of many
544+
// small array<null>-style fields cannot over-allocate in aggregate.
545+
SystemLimitException.checkMaxCollectionAllocation(count);
534546
} else {
535547
GenericDatumReader.ensureAvailableCollectionBytes(decoder, count, elementType);
536548
}

lang/java/avro/src/main/java/org/apache/avro/reflect/ReflectDatumReader.java

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ protected Object readArray(Object old, Schema expected, ResolvingDecoder in) thr
153153
// eager allocation before any element is read.
154154
ensureAvailableCollectionBytes(in, l, expectedType);
155155
if (isZeroByteSchema(expectedType)) {
156-
SystemLimitException.checkMaxCollectionAllocation(0, l);
156+
SystemLimitException.checkMaxCollectionAllocation(l);
157157
}
158158
Object array = newArray(old, (int) l, expected);
159159
if (array instanceof Collection) {
@@ -209,7 +209,7 @@ private Object readObjectArray(Object[] array, Schema expectedType, long l, Reso
209209
array[index] = element;
210210
index++;
211211
}
212-
} while ((l = nextArrayBlock(in, expectedType, index, zeroByte)) > 0);
212+
} while ((l = nextArrayBlock(in, expectedType, zeroByte)) > 0);
213213
} else {
214214
do {
215215
int limit = index + (int) l;
@@ -218,7 +218,7 @@ private Object readObjectArray(Object[] array, Schema expectedType, long l, Reso
218218
array[index] = element;
219219
index++;
220220
}
221-
} while ((l = nextArrayBlock(in, expectedType, index, zeroByte)) > 0);
221+
} while ((l = nextArrayBlock(in, expectedType, zeroByte)) > 0);
222222
}
223223
return array;
224224
}
@@ -228,23 +228,20 @@ private Object readCollection(Collection<Object> c, Schema expectedType, long l,
228228
LogicalType logicalType = expectedType.getLogicalType();
229229
Conversion<?> conversion = getData().getConversionFor(logicalType);
230230
boolean zeroByte = isZeroByteSchema(expectedType);
231-
long count = 0;
232231
if (logicalType != null && conversion != null) {
233232
do {
234233
for (int i = 0; i < l; i++) {
235234
Object element = readWithConversion(null, expectedType, logicalType, conversion, in);
236235
c.add(element);
237236
}
238-
count += l;
239-
} while ((l = nextArrayBlock(in, expectedType, count, zeroByte)) > 0);
237+
} while ((l = nextArrayBlock(in, expectedType, zeroByte)) > 0);
240238
} else {
241239
do {
242240
for (int i = 0; i < l; i++) {
243241
Object element = readWithoutConversion(null, expectedType, in);
244242
c.add(element);
245243
}
246-
count += l;
247-
} while ((l = nextArrayBlock(in, expectedType, count, zeroByte)) > 0);
244+
} while ((l = nextArrayBlock(in, expectedType, zeroByte)) > 0);
248245
}
249246
return c;
250247
}
@@ -254,22 +251,22 @@ private Object readCollection(Collection<Object> c, Schema expectedType, long l,
254251
* {@link org.apache.avro.generic.GenericDatumReader#readArray}: bound the
255252
* declared count against the bytes remaining, and for element types whose
256253
* minimum encoded size is zero bound the cumulative allocation (which the
257-
* bytes-remaining check cannot). This closes the gap where a large logical
258-
* array split across multiple blocks would otherwise pass only the first
259-
* block's guard.
254+
* bytes-remaining check cannot). The zero-byte allocation cap is cumulative
255+
* across the enclosing datum scope (see
256+
* {@link org.apache.avro.SystemLimitException}), closing the gap where a large
257+
* logical array split across multiple blocks would otherwise pass only the
258+
* first block's guard.
260259
*
261260
* @param in the decoder
262261
* @param expectedType the array element schema
263-
* @param existing the number of elements already read
264262
* @param zeroByte whether the element type's minimum encoded size is zero
265263
* @return the validated next block count
266264
*/
267-
private long nextArrayBlock(ResolvingDecoder in, Schema expectedType, long existing, boolean zeroByte)
268-
throws IOException {
265+
private long nextArrayBlock(ResolvingDecoder in, Schema expectedType, boolean zeroByte) throws IOException {
269266
long l = in.arrayNext();
270267
ensureAvailableCollectionBytes(in, l, expectedType);
271268
if (zeroByte && l > 0) {
272-
SystemLimitException.checkMaxCollectionAllocation(existing, l);
269+
SystemLimitException.checkMaxCollectionAllocation(l);
273270
}
274271
return l;
275272
}

0 commit comments

Comments
 (0)