AVRO-4327: [Java] Bound decode recursion depth - #3928
Conversation
Recursive schemas (e.g. a linked list or tree) let a small, hostile payload drive arbitrarily deep nesting during binary decoding, exhausting the call stack with a StackOverflowError before any allocation limit is reached. Add a configurable maximum decode nesting depth, enforced by counting structural descents into records, arrays, maps and unions and rejecting input that nests deeper than the limit with a bounded SystemLimitException. The default is 100 (matching Protocol Buffers) and is configurable via the org.apache.avro.limits.decode.maxDepth system property. The depth is tracked in the existing per-thread decode scope so a reader reused concurrently cannot corrupt another thread's counter and no reader method signatures change. Both reader paths are guarded: the classic GenericDatumReader (and its Specific/Reflect subclasses) via readWithoutConversion, and the FastReaderBuilder record/map/union/array readers.
There was a problem hiding this comment.
Pull request overview
Adds a configurable maximum decode nesting depth in the Java Avro runtime to harden recursive-schema decoding against stack exhaustion, turning potential StackOverflowError crashes into bounded SystemLimitException failures.
Changes:
- Introduces
org.apache.avro.limits.decode.maxDepthwith a default of 100 and per-thread tracking inSystemLimitException. - Enforces depth accounting in both classic decoding (
GenericDatumReader.readWithoutConversion) and fast-reader paths (FastReaderBuilderunion/array/record/map readers). - Adds regression and unit tests covering default behavior, custom limit, and outer-scope reset semantics.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java | Adds decode-depth limit property/default, per-thread counter, and increment/decrement APIs; resets depth at outermost datum scope. |
| lang/java/avro/src/main/java/org/apache/avro/generic/GenericDatumReader.java | Wraps structural type decoding with depth increment/decrement and factors structural dispatch into a helper. |
| lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java | Adds depth guarding around fast-reader union/array/record/map descents. |
| lang/java/avro/src/test/java/org/apache/avro/TestSystemLimitException.java | Adds unit tests for decode-depth counter, custom limit, and outer-scope reset; updates property reset. |
| lang/java/avro/src/test/java/org/apache/avro/generic/TestDecodeRecursionDepth.java | Adds regression test for deep recursive payload rejection on both classic and fast reader paths. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Address review feedback: the decode-depth guard only wrapped the read path, but skipping a writer-only field during resolution, the fast reader's skip steps, and BinaryData.compare all descend into nested records/arrays/maps/unions with the same recursive call chain and could still overflow the stack on a deeply nested recursive value. Apply the same increment/decrement depth guard to GenericDatumReader's structural skip cases and to BinaryData.compare (which also resets the depth at the top-level comparison). Add regression tests that a deeply nested payload is rejected with a bounded SystemLimitException when skipped and when compared, and clarify the outer-scope reset unit test.
Address review feedback: the fast reader's array descent incremented the decode depth before opening the collection-allocation scope. When the fast reader runs standalone with a top-level array, that scope is the outermost datum boundary and resets the decode depth, so it wiped the just-incremented level (under-counting the array's nesting) and a stale depth could trip the limit before the reset cleared it. Open the collection-allocation scope first (so it resets any stale depth at the datum boundary), then count this array's level, with the depth decrement and scope end both in finally blocks. Add a regression test for the standalone fast-reader top-level array path.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (4)
lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java:657
RecordReader.read(...)now guards recursion depth, but it still doesn’t open a collection-allocation/decode scope. When the fast reader is used standalone with a top-level record, per-datum zero-byte allocation accounting won’t be cumulative across multiple nested arrays/maps inside the record (each array opens its own outermost scope and resets the running total), and stale decode depth won’t be reset at the datum boundary. Wrapping the record read inbeginCollectionAllocationScope()/endCollectionAllocationScope()(before incrementing depth) makes standalone usage consistent withGenericDatumReader.read(...).
public Object read(Object reuse, Decoder decoder) throws IOException {
// Bound decode nesting depth: a recursive schema fed deeply nested data
// would otherwise overflow the stack via this recursive descent.
SystemLimitException.incrementDecodeDepth();
try {
lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java:683
MapReader.read(...)increments decode depth but (unlike the array reader andGenericDatumReader.read(...)) doesn’t open a collection-allocation/decode scope. IfFastReaderBuilderis used standalone with a top-level map, stale depth won’t be reset at the datum boundary and zero-byte allocation accounting won’t be cumulative across nested collections in the map’s values. Consider scoping the read withbeginCollectionAllocationScope()/endCollectionAllocationScope()and doing so before the depth increment (so the outer-scope reset can’t wipe the increment).
@Override
public Object read(Object reuse, Decoder decoder) throws IOException {
SystemLimitException.incrementDecodeDepth();
try {
long l = decoder.readMapStart();
lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java:425
createUnionReader(...)increments the decode-depth counter but never opens a collection-allocation/decode scope. WhenFastReaderBuilderis used standalone (withoutGenericDatumReader.read(...)), this means (a) stale per-thread depth is not reset at the datum boundary, and (b) zero-byte allocation accounting won’t be cumulative across the whole datum unless some nested array happens to open the scope first. Opening the scope before incrementing depth (mirroring the array reader’s ordering) makes standalone union-top-level reads consistent and prevents the outer-scope reset from wiping the union increment.
This issue also appears in the following locations of the same file:
- line 653
- line 679
return reusingReader((reuse, decoder) -> {
SystemLimitException.incrementDecodeDepth();
try {
final int selection = decoder.readIndex();
if (selection < 0 || selection >= unionReaders.length) {
lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java:548
resetLimits()assignsmaxDecodeDepthusinggetLimitFromProperty(...), which usesInteger.parseUnsignedInt. Values in the unsigned range[2^31, 2^32-1]parse successfully but wrap to a negativeint, makingmaxDecodeDepthnegative and causing all structural decodes to fail (decodeDepth >= maxDecodeDepthis immediately true). Add an explicit negative check (and fallback) for this new property so oversized values don’t silently brick decoding.
// zero-byte allocation cap consistent with the other collection limits even
// when it is configured (or derived from a very large heap) above that.
maxCollectionAllocation = Math.min(maxCollectionAllocation, MAX_ARRAY_VM_LIMIT);
maxDecodeDepth = getLimitFromProperty(MAX_DECODE_DEPTH_PROPERTY, DEFAULT_MAX_DECODE_DEPTH);
}
CodeQL flagged a comparison of a narrow int loop counter against a wider long block count in the fast reader's MapReader. A map block count above Integer.MAX_VALUE would overflow the int counter and never satisfy the loop condition. Use a long counter, matching the array reader in the same file.
What is the purpose of the change
Java SDK implementation of AVRO-4302 (parent). Recursive schemas (e.g. a
linked list or tree) let a small, hostile payload drive arbitrarily deep
nesting during binary decoding, exhausting the call stack with a
StackOverflowErrorbefore any allocation limit is reached.This adds a configurable maximum decode nesting depth, enforced by counting
structural descents into records, arrays, maps and unions and rejecting input
that nests deeper than the limit with a bounded
SystemLimitExceptioninsteadof a
StackOverflowError. The default is 100 (matching Protocol Buffers) andis configurable via the
org.apache.avro.limits.decode.maxDepthsystemproperty. The depth is tracked in the existing per-thread decode scope, so a
reader reused concurrently cannot corrupt another thread's counter and no
reader method signatures change. Both reader paths are guarded: the classic
GenericDatumReader(and its Specific/Reflect subclasses) viareadWithoutConversion, and theFastReaderBuilderrecord/map/union/arrayreaders.
Verifying this change
This change added tests and can be verified as follows:
TestDecodeRecursionDepth: a ~100k-deep recursive linked-list payloadis rejected with a bounded
SystemLimitException(not aStackOverflowError)on both the classic and fast reader paths, while a moderately nested value
within the limit still decodes.
TestSystemLimitExceptionfor the depth counter, thecustom-limit property, and the outer-scope reset.
without-fast-reader).
Documentation
on
SystemLimitExceptionalongside the existing collection/decompress limits)