This is an automated email from the ASF dual-hosted git repository. jamesbognar pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/juneau.git
commit 25a0ec407572329e221930a1b5bbb8d44a40ee26 Author: James Bognar <[email protected]> AuthorDate: Sun Aug 16 21:22:14 2026 -0400 READY-389/390: Shared parse-depth budget + narrowed OOM catch in ParserSession Adds a shared, configurable nesting-depth budget to ParserSession (READY-389), enforced by the CBOR and Protobuf parsers alongside the existing MsgPack depth guard, plus new CborParser_ParseDepth_Test / ProtobufParser_ParseDepth_Test covering it. Narrows ParserSession's OutOfMemoryError catch to wrap only the local allocation call it exists to guard (READY-390), instead of a broad try block that could mask unrelated OOMs. Combined into one commit because both items touch ParserSession.java / ParserSession_Test.java. --- .../juneau/marshall/cbor/CborParserSession.java | 15 ++- .../marshall/msgpack/MsgPackParserSession.java | 26 +---- .../juneau/marshall/parser/ParserSession.java | 105 +++++++++++++++++++-- .../marshall/protobuf/ProtobufParserSession.java | 16 +++- .../marshall/cbor/CborParser_ParseDepth_Test.java | 73 ++++++++++++++ .../juneau/marshall/parser/ParserSession_Test.java | 102 ++++++++++++++++++-- .../protobuf/ProtobufParser_ParseDepth_Test.java | 86 +++++++++++++++++ 7 files changed, 381 insertions(+), 42 deletions(-) diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/cbor/CborParserSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/cbor/CborParserSession.java index 8e8e76a30d..e10bfaa8a9 100644 --- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/cbor/CborParserSession.java +++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/cbor/CborParserSession.java @@ -151,6 +151,19 @@ public class CborParserSession extends InputStreamParserSession implements Token } } + /* + * Workhorse entry point. Wraps readAnything0 with the shared ParserSession recursion-depth guard so that + * an adversarial deeply-nested document fails with a ParseException instead of a StackOverflowError. + */ + <T> T readAnything(ClassMeta<?> eType, CborInputStream is, Object outer, BeanPropertyMeta pMeta) throws IOException, ParseException, ExecutableException { + enterParseDepth(); + try { + return readAnything0(eType, is, outer, pMeta); + } finally { + exitParseDepth(); + } + } + /* * Workhorse method. */ @@ -158,7 +171,7 @@ public class CborParserSession extends InputStreamParserSession implements Token "java:S3776", // Cognitive complexity acceptable for this specific logic "java:S6541" // Single-threaded session contexts do not require synchronization }) - <T> T readAnything(ClassMeta<?> eType, CborInputStream is, Object outer, BeanPropertyMeta pMeta) throws IOException, ParseException, ExecutableException { + private <T> T readAnything0(ClassMeta<?> eType, CborInputStream is, Object outer, BeanPropertyMeta pMeta) throws IOException, ParseException, ExecutableException { if (eType == null) eType = object(); diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/msgpack/MsgPackParserSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/msgpack/MsgPackParserSession.java index e52aa04785..d672341d66 100644 --- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/msgpack/MsgPackParserSession.java +++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/msgpack/MsgPackParserSession.java @@ -92,22 +92,9 @@ public class MsgPackParserSession extends InputStreamParserSession implements To return new Builder(assertArgNotNull(ARG_ctx, ctx)); } - /** - * Maximum databind parse-recursion depth. - * - * <p> - * The databind {@link #readAnything(ClassMeta, MsgPackInputStream, Object, BeanPropertyMeta) readAnything} - * path recurses once per nesting level; this bound makes an adversarial deeply-nested document fail with a - * {@link ParseException} instead of a {@link StackOverflowError}. The token-cursor path is iterative and - * unaffected. - */ - private static final int MAX_PARSE_DEPTH = 1000; - private final boolean nativeMode; private final int maxLength; - private int parseDepth; - /** * Constructor. * @@ -163,19 +150,16 @@ public class MsgPackParserSession extends InputStreamParserSession implements To } /* - * Workhorse entry point. Wraps {@link #readAnything0} with a recursion-depth guard so that an - * adversarial deeply-nested document fails with a {@link ParseException} instead of a - * {@link StackOverflowError}. + * Workhorse entry point. Wraps readAnything0 with the shared ParserSession recursion-depth guard so that + * an adversarial deeply-nested document fails with a ParseException instead of a StackOverflowError. The + * token-cursor path is iterative and unaffected. */ <T> T readAnything(ClassMeta<?> eType, MsgPackInputStream is, Object outer, BeanPropertyMeta pMeta) throws IOException, ParseException, ExecutableException { - if (++parseDepth > MAX_PARSE_DEPTH) { - parseDepth--; - throw new ParseException(this, "Maximum parse depth exceeded (%s).", MAX_PARSE_DEPTH); - } + enterParseDepth(); try { return readAnything0(eType, is, outer, pMeta); } finally { - parseDepth--; + exitParseDepth(); } } diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserSession.java index 53c38e7213..8d6aa6343b 100644 --- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserSession.java +++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserSession.java @@ -28,6 +28,7 @@ import java.time.*; import java.time.Duration; import java.time.temporal.*; import java.util.*; +import java.util.function.*; import javax.xml.datatype.*; @@ -70,11 +71,18 @@ public class ParserSession extends MarshallingSession { private static final String PROP_schema = "schema"; private static final String PROP_trimStrings = "trimStrings"; private static final String PROP_nulls = "nulls"; + private static final String PROP_maxParseDepth = "maxParseDepth"; private static final String PROP_ParserSession_javaMethod = "ParserSession.javaMethod"; private static final String PROP_ParserSession_outer = "ParserSession.outer"; private static final String PROP_ParserSession_schema = "ParserSession.schema"; private static final String PROP_ParserSession_trimStrings = "ParserSession.trimStrings"; private static final String PROP_ParserSession_nulls = "ParserSession.nulls"; + private static final String PROP_ParserSession_maxParseDepth = "ParserSession.maxParseDepth"; + + /** + * Default value for {@link Builder#maxParseDepth(int)}. + */ + protected static final int DEFAULT_MAX_PARSE_DEPTH = 1000; // Argument name constants for assertArgNotNull private static final String ARG_ctx = "ctx"; @@ -92,6 +100,7 @@ public class ParserSession extends MarshallingSession { private Object outer; private boolean trimStrings; private Nulls nulls; + private int maxParseDepth = DEFAULT_MAX_PARSE_DEPTH; private Parser ctx; /** @@ -140,6 +149,21 @@ public class ParserSession extends MarshallingSession { return self(); } + /** + * The shared recursive-parse-depth budget honored by binary codec sessions (CBOR, Protobuf, MsgPack) + * at their recursive databind entry points. + * + * @param value + * The new property value. + * <br>Defaults to {@value ParserSession#DEFAULT_MAX_PARSE_DEPTH}. + * @return This object. + * @see ParserSession#enterParseDepth() + */ + public SELF maxParseDepth(int value) { + maxParseDepth = value; + return self(); + } + @Override /* Overridden from Builder */ public SELF property(String key, Object value) { if (key == null) { @@ -157,6 +181,8 @@ public class ParserSession extends MarshallingSession { return trimStrings(cvt(value, Boolean.class)); case PROP_nulls, PROP_ParserSession_nulls: return nulls(cvt(value, Nulls.class)); + case PROP_maxParseDepth, PROP_ParserSession_maxParseDepth: + return maxParseDepth(cvt(value, Integer.class)); default: super.property(key, value); return self(); @@ -315,6 +341,7 @@ public class ParserSession extends MarshallingSession { private final Object outer; private final boolean trimStrings; private final Nulls nulls; + private final int maxParseDepth; private final Parser ctx; private final ParserListener listener; private final Deque<StringBuilder> sbStack; @@ -323,6 +350,7 @@ public class ParserSession extends MarshallingSession { private Object parentBean; private Position mark = new Position(-1); private ParserPipe pipe; + private int parseDepth; /** * Constructor. @@ -339,6 +367,7 @@ public class ParserSession extends MarshallingSession { schema = builder.schema; trimStrings = builder.trimStrings; nulls = builder.nulls == null ? Nulls.NOT_SET : builder.nulls; + maxParseDepth = builder.maxParseDepth; listener = BeanInstantiator.createOrNull(ctx.getListener()); sbStack = new ArrayDeque<>(); } @@ -688,8 +717,6 @@ public class ParserSession extends MarshallingSession { throw e; } catch (@SuppressWarnings("unused") StackOverflowError e) { throw new ParseException(this, "Depth too deep. Stack overflow occurred."); - } catch (@SuppressWarnings("unused") OutOfMemoryError e) { - throw new ParseException(this, "Out of memory occurred. Input too large to parse."); } catch (IOException e) { throw new ParseException(this, e, "I/O exception occurred. exception=%s, message=%s.", cns(e), localizedMessage(e)); } catch (Exception e) { @@ -725,8 +752,6 @@ public class ParserSession extends MarshallingSession { throw e; } catch (@SuppressWarnings("unused") StackOverflowError e) { throw new ParseException(this, "Depth too deep. Stack overflow occurred."); - } catch (@SuppressWarnings("unused") OutOfMemoryError e) { - throw new ParseException(this, "Out of memory occurred. Input too large to parse."); } catch (IOException e) { throw new ParseException(this, e, "I/O exception occurred. exception=%s, message=%s.", cns(e), localizedMessage(e)); } catch (Exception e) { @@ -803,8 +828,6 @@ public class ParserSession extends MarshallingSession { throw e; } catch (@SuppressWarnings("unused") StackOverflowError e) { throw new ParseException(this, "Depth too deep. Stack overflow occurred."); - } catch (@SuppressWarnings("unused") OutOfMemoryError e) { - throw new ParseException(this, "Out of memory occurred. Input too large to parse."); } catch (Exception e) { throw new ParseException(this, e, "Exception occurred. exception=%s, message=%s.", cns(e), localizedMessage(e)); } finally { @@ -844,8 +867,6 @@ public class ParserSession extends MarshallingSession { public final <T> void readToBeanConsumer(Object input, BeanConsumer<T> consumer, Class<T> elementType) throws ParseException, IOException { try (var p = createPipe(input)) { doReadToBeanConsumer(p, consumer, elementType); - } catch (@SuppressWarnings("unused") OutOfMemoryError e) { - throw new ParseException(this, "Out of memory occurred. Input too large to parse."); } } @@ -1350,6 +1371,71 @@ public class ParserSession extends MarshallingSession { return Iso8601Utils.parseTemporal(s, cm, getTemporalFormat(), getTimeZone()); } + /** + * Enters one level of recursive databind parsing, enforcing the shared {@link Builder#maxParseDepth(int)} + * budget (default {@value #DEFAULT_MAX_PARSE_DEPTH}). + * + * <p> + * Binary codec sessions (CBOR, Protobuf, MsgPack) call this at their recursive databind entry point before + * descending into a nested array/map/message element, and must call {@link #exitParseDepth()} in a matching + * <jk>finally</jk> block regardless of outcome. Exceeding the budget throws a {@link ParseException} so an + * adversarial deeply-nested document fails cleanly instead of exhausting the call stack with a + * {@link StackOverflowError}. + * + * <p> + * Intentionally not applied to the JSON/XML/text parsers, whose recursive-descent parsers already rely on + * {@link StackOverflowError} conversion at the outer parse boundary. + * + * @throws ParseException If the configured maximum parse depth was exceeded. + */ + protected final void enterParseDepth() throws ParseException { + if (++parseDepth > maxParseDepth) { + parseDepth--; + throw new ParseException(this, "Maximum parse depth exceeded (%s).", maxParseDepth); + } + } + + /** + * Exits one level of recursive databind parsing previously entered via {@link #enterParseDepth()}. + * + * <p> + * Must be called exactly once per successful {@link #enterParseDepth()} call, in a matching <jk>finally</jk> + * block so the depth counter is restored even when the nested parse throws. + */ + protected final void exitParseDepth() { + parseDepth--; + } + + /** + * Runs a local, parser-controlled allocation (e.g. pre-sizing an array/collection/buffer from a + * wire-declared count) and converts an {@link OutOfMemoryError} thrown specifically from it into a bounded + * {@link ParseException}. + * + * <p> + * Only an {@link OutOfMemoryError} thrown from {@code alloc}'s own execution is converted here. Any other + * {@link OutOfMemoryError} — one raised elsewhere in the call stack, signaling a JVM that is + * genuinely running out of heap — is <b>not</b> this method's concern and is never observed by it; a + * dying JVM is not a client input-validation failure and must not be reported as one. Format-specific + * parser sessions should route untrusted-length-driven local allocations through this helper instead of + * relying on a blanket catch at an outer parse boundary. + * + * @param <T> The allocated object's type. + * @param what A short human-readable description of what's being allocated, used in the exception message + * if the allocation fails. + * <br>Must not be <jk>null</jk>. + * @param alloc The allocation to attempt. + * <br>Must not be <jk>null</jk>. + * @return The allocated object. + * @throws ParseException If {@code alloc} itself threw an {@link OutOfMemoryError}. + */ + protected final <T> T allocateLocal(String what, Supplier<T> alloc) throws ParseException { + try { + return alloc.get(); + } catch (@SuppressWarnings("unused") OutOfMemoryError e) { + throw new ParseException(this, "Out of memory occurred. Input too large to parse. Allocation: %s", what); + } + } + /** * Marks the current position. */ @@ -1409,7 +1495,8 @@ public class ParserSession extends MarshallingSession { .a(PROP_listener, listener) .a(PROP_outer, outer) .a(PROP_trimStrings, trimStrings) - .a(PROP_nulls, nulls); + .a(PROP_nulls, nulls) + .a(PROP_maxParseDepth, maxParseDepth); } /** diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/protobuf/ProtobufParserSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/protobuf/ProtobufParserSession.java index 3a1c65ac4f..ff0b3c2495 100644 --- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/protobuf/ProtobufParserSession.java +++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/protobuf/ProtobufParserSession.java @@ -108,11 +108,25 @@ public class ProtobufParserSession extends InputStreamParserSession { return (T)readMessage(type, is, getOuter()); } + /* + * Workhorse entry point. Wraps readMessage0 with the shared ParserSession recursion-depth guard so that + * an adversarial deeply-nested message (via nested MESSAGE fields, TAGGED_REPEATED elements, or map + * entry/value sub-messages) fails with a ParseException instead of a StackOverflowError. + */ + private Object readMessage(ClassMeta<?> type, ProtobufReader is, Object outer) throws IOException, ParseException, ExecutableException { + enterParseDepth(); + try { + return readMessage0(type, is, outer); + } finally { + exitParseDepth(); + } + } + @SuppressWarnings({ "java:S3776", // Cognitive complexity acceptable for the tag-loop dispatch "java:S6541" // Brain method acceptable for the parse workhorse }) - private Object readMessage(ClassMeta<?> type, ProtobufReader is, Object outer) throws IOException, ParseException, ExecutableException { + private Object readMessage0(ClassMeta<?> type, ProtobufReader is, Object outer) throws IOException, ParseException, ExecutableException { if (type.isMap()) return readMapMessage(type, is); var pcm = ctx.getProtobufClassMeta(type); diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/cbor/CborParser_ParseDepth_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/cbor/CborParser_ParseDepth_Test.java new file mode 100644 index 0000000000..2d725261ee --- /dev/null +++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/cbor/CborParser_ParseDepth_Test.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.marshall.cbor; + +import static org.apache.juneau.commons.utils.StringUtils.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; + +import org.apache.juneau.*; +import org.apache.juneau.marshall.parser.*; +import org.junit.jupiter.api.*; + +/** + * Recursion-depth guard tests for {@link CborParserSession}'s databind parse path (READY-389: shared + * {@link ParserSession} parse-depth budget applied to CBOR, mirroring MsgPack's prior per-codec guard). + * + * <p> + * Before this fix, {@code CborParserSession.readAnything} recursed once per nesting level with no depth + * cap of its own; an adversarial deeply-nested array/map would only fail once the JVM call stack was + * actually exhausted ({@link StackOverflowError}), rather than failing gracefully via a bounded + * {@link ParseException} the way MsgPack already did. + */ +class CborParser_ParseDepth_Test extends TestBase { + + @Test void a01_deeplyNestedArraysFailWithParseException() { + // 1100 nested definite-length-1 array headers (CBOR major type 4, additional info 1 -> 0x81) then a + // terminal UINT 0 (0x00) -> exceeds the shared ParserSession maxParseDepth budget (default 1000). + var sb = new StringBuilder(); + for (var i = 0; i < 1100; i++) + sb.append("81 "); + sb.append("00"); + var input = sb.toString(); + var e = assertThrows(ParseException.class, () -> CborParser.DEFAULT.read(fromSpacedHex(input), Object.class)); + var msg = String.valueOf(e.getMessage()); + // Graceful depth-failure ParseException; the soft maxParseDepth guard is expected to fire before any + // real StackOverflowError, but a constrained CI thread stack falling back to the StackOverflowError + // wrapper is also an acceptable pass (mirrors the MsgPack conformance test's tolerance). + assertTrue( + msg.contains("Maximum parse depth exceeded") || msg.contains("Depth too deep"), + "Expected a graceful depth-failure ParseException. Actual:\n" + msg); + } + + @Test void a02_moderateNestingStillParses() throws Exception { + // 10 nested arrays then a terminal UINT 5 -> well within the depth budget; legitimate shallow + // payloads must be unaffected by the new guard. + var sb = new StringBuilder(); + for (var i = 0; i < 10; i++) + sb.append("81 "); + sb.append("05"); + Object o = CborParser.DEFAULT.read(fromSpacedHex(sb.toString()), Object.class); + for (var i = 0; i < 10; i++) { + var l = (List<?>) o; + assertEquals(1, l.size()); + o = l.get(0); + } + assertEquals(5L, o); + } +} diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/parser/ParserSession_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/parser/ParserSession_Test.java index 07b9e3a7e5..11c820e1d3 100644 --- a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/parser/ParserSession_Test.java +++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/parser/ParserSession_Test.java @@ -818,6 +818,9 @@ class ParserSession_Test extends TestBase { ExposingSession(JsonParser p) { super(create(p)); } + ExposingSession(Builder<?> builder) { + super(builder); + } public boolean exposeIsTrimStrings() { return isTrimStrings(); } public boolean exposeIsAutoCloseStreams() { return isAutoCloseStreams(); } public boolean exposeIsUnbuffered() { return isUnbuffered(); } @@ -840,6 +843,9 @@ class ParserSession_Test extends TestBase { public java.time.Period exposeParsePeriod(String s) { return readPeriod(s); } public void exposeMark() { mark(); } public void exposeUnmark() { unmark(); } + public void exposeEnterParseDepth() throws ParseException { enterParseDepth(); } + public void exposeExitParseDepth() { exitParseDepth(); } + public <T> T exposeAllocateLocal(String what, java.util.function.Supplier<T> alloc) throws ParseException { return allocateLocal(what, alloc); } public org.apache.juneau.marshall.collections.JsonMap exposeGetLastLocation() { return getLastLocation(); } public ParserPipe exposeSetPipe(ParserPipe pp) { return setPipe(pp); } public Map<String,Object> doParseIntoMap_callDirect() throws Exception { @@ -976,10 +982,12 @@ class ParserSession_Test extends TestBase { } // ----------------------------------------------------------------------------------------------------------------- - // t - allocation-failure guard: OutOfMemoryError from within a parse degrades to a bounded ParseException + // t - narrowed allocation-failure guard: an OutOfMemoryError from arbitrary downstream code (NOT routed + // through allocateLocal) now propagates as a raw OutOfMemoryError instead of being wrapped as a + // ParseException. Only allocateLocal's own local allocation is converted (see category u below). // ----------------------------------------------------------------------------------------------------------------- - /** Test session whose parse paths always fail with an OutOfMemoryError, to exercise the allocation-failure guard. */ + /** Test session whose parse paths always fail with an OutOfMemoryError, to exercise the narrowed guard. */ public static class OomSession extends ParserSession { OomSession(JsonParser p) { super(create(p)); @@ -994,21 +1002,95 @@ class ParserSession_Test extends TestBase { } } - @Test void t01_readInner_outOfMemoryProducesParseException() { + @Test void t01_readInner_outOfMemoryPropagatesUnwrapped() { + // doRead is arbitrary downstream code, not a local allocation routed through allocateLocal -- the OOME + // must propagate as-is rather than being reported as a client parse error. var s = new OomSession(JsonParser.DEFAULT); - var e = assertThrows(ParseException.class, () -> s.read("{}", Object.class)); - assertTrue(e.getMessage().contains("Out of memory"), e.getMessage()); + assertThrows(OutOfMemoryError.class, () -> s.read("{}", Object.class)); } - @Test void t02_readArgs_outOfMemoryProducesParseException() { + @Test void t02_readArgs_outOfMemoryPropagatesUnwrapped() { var s = new OomSession(JsonParser.DEFAULT); - var e = assertThrows(ParseException.class, () -> s.readArgs("[]", new Type[]{Object.class})); - assertTrue(e.getMessage().contains("Out of memory"), e.getMessage()); + assertThrows(OutOfMemoryError.class, () -> s.readArgs("[]", new Type[]{Object.class})); + } + + @Test void t03_readIntoCollection_outOfMemoryPropagatesUnwrapped() { + var s = new OomSession(JsonParser.DEFAULT); + assertThrows(OutOfMemoryError.class, () -> s.readIntoCollection("[]", new ArrayList<>(), Object.class)); } - @Test void t03_readIntoCollection_outOfMemoryProducesParseException() { + @Test void t04_readToBeanConsumer_outOfMemoryPropagatesUnwrapped() { + // doReadToBeanConsumer's default impl calls doRead (List.class) internally, so this also exercises the + // removed outer-boundary catch on readToBeanConsumer. var s = new OomSession(JsonParser.DEFAULT); - var e = assertThrows(ParseException.class, () -> s.readIntoCollection("[]", new ArrayList<>(), Object.class)); + BeanConsumer<Object> a = o -> { /* no-op */ }; + assertThrows(OutOfMemoryError.class, () -> s.readToBeanConsumer("[]", a, Object.class)); + } + + // ----------------------------------------------------------------------------------------------------------------- + // u - shared parse-depth budget (enterParseDepth/exitParseDepth + Builder#maxParseDepth) + // ----------------------------------------------------------------------------------------------------------------- + + @Test void u01_enterExitParseDepth_roundTripsWithinBudget() throws Exception { + var s = new ExposingSession(JsonParser.DEFAULT); + // Default budget (1000) comfortably allows a handful of nested enter/exit calls. + s.exposeEnterParseDepth(); + s.exposeEnterParseDepth(); + s.exposeExitParseDepth(); + s.exposeExitParseDepth(); + // Depth counter is back at 0; another full round-trip should still succeed. + s.exposeEnterParseDepth(); + s.exposeExitParseDepth(); + } + + @Test void u02_enterParseDepth_exceedsConfiguredBudget_throwsParseException() throws Exception { + // Build a session with maxParseDepth(2) via the builder, then drive it past the budget. + var builder = (ParserSession.Builder<?>) ParserSession.create(JsonParser.DEFAULT); + builder.maxParseDepth(2); + var s = new ExposingSession(builder); + s.exposeEnterParseDepth(); + s.exposeEnterParseDepth(); + var e = assertThrows(ParseException.class, s::exposeEnterParseDepth); + assertTrue(e.getMessage().contains("Maximum parse depth exceeded (2)"), e.getMessage()); + } + + @Test void u03_builder_property_maxParseDepth_unqualified() throws Exception { + var builder = (ParserSession.Builder<?>) ParserSession.create(JsonParser.DEFAULT); + builder.property("maxParseDepth", "2"); + var s = new ExposingSession(builder); + s.exposeEnterParseDepth(); + s.exposeEnterParseDepth(); + assertThrows(ParseException.class, s::exposeEnterParseDepth); + } + + @Test void u04_builder_property_maxParseDepth_qualified() throws Exception { + var builder = (ParserSession.Builder<?>) ParserSession.create(JsonParser.DEFAULT); + builder.property("ParserSession.maxParseDepth", "1"); + var s = new ExposingSession(builder); + s.exposeEnterParseDepth(); + assertThrows(ParseException.class, s::exposeEnterParseDepth); + } + + @Test void u05_builder_maxParseDepth_defaultIsAThousand() throws Exception { + var s = new ExposingSession(JsonParser.DEFAULT); + for (var i = 0; i < 1000; i++) + s.exposeEnterParseDepth(); + assertThrows(ParseException.class, s::exposeEnterParseDepth); + } + + // ----------------------------------------------------------------------------------------------------------------- + // v - allocateLocal: the ONLY remaining place where an OutOfMemoryError is converted to a bounded ParseException + // ----------------------------------------------------------------------------------------------------------------- + + @Test void v01_allocateLocal_success_returnsSupplierValue() throws Exception { + var s = new ExposingSession(JsonParser.DEFAULT); + var result = s.exposeAllocateLocal("test-array", () -> new int[10]); + assertEquals(10, result.length); + } + + @Test void v02_allocateLocal_oomFromSupplier_wrappedAsParseException() { + var s = new ExposingSession(JsonParser.DEFAULT); + var e = assertThrows(ParseException.class, () -> s.exposeAllocateLocal("huge-buffer", () -> { throw new OutOfMemoryError("simulated"); })); assertTrue(e.getMessage().contains("Out of memory"), e.getMessage()); } } diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/protobuf/ProtobufParser_ParseDepth_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/protobuf/ProtobufParser_ParseDepth_Test.java new file mode 100644 index 0000000000..9bb7f74483 --- /dev/null +++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/protobuf/ProtobufParser_ParseDepth_Test.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.marshall.protobuf; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.*; + +import org.apache.juneau.*; +import org.apache.juneau.marshall.parser.*; +import org.junit.jupiter.api.*; + +/** + * Recursion-depth guard tests for {@link ProtobufParserSession}'s nested-message parse path (READY-389: + * shared {@link ParserSession} parse-depth budget applied to Protobuf, mirroring MsgPack's prior per-codec + * guard). + * + * <p> + * Before this fix, {@code ProtobufParserSession.readMessage} recursed once per nested {@code MESSAGE} field + * (or map entry/value sub-message) with no depth cap; an adversarial deeply-nested message would only fail + * once the JVM call stack was actually exhausted ({@link StackOverflowError}), rather than failing gracefully + * via a bounded {@link ParseException}. + */ +class ProtobufParser_ParseDepth_Test extends TestBase { + + public static class Nested { + public Nested child; + public Nested() {} + } + + /** + * Hand-builds a protobuf message with {@code depth} levels of nesting via field 1 (a {@code Nested child} + * MESSAGE field), the innermost level being an empty (leafless) sub-message. + */ + private static byte[] nestedMessage(int depth) { + var msg = new byte[0]; + for (var i = 0; i < depth; i++) { + var out = new ByteArrayOutputStream(); + var w = new ProtobufWriter(out); + w.writeTag(1, WireType.LEN); + w.writeLenDelimited(msg); + msg = out.toByteArray(); + } + return msg; + } + + @Test + void a01_deeplyNestedMessagesFailWithParseException() { + // 1100 levels of nested "child" MESSAGE fields -> exceeds the shared ParserSession maxParseDepth + // budget (default 1000). + var msg = nestedMessage(1100); + var e = assertThrows(ParseException.class, () -> ProtobufParser.DEFAULT.read(msg, Nested.class)); + var text = String.valueOf(e.getMessage()); + assertTrue( + text.contains("Maximum parse depth exceeded") || text.contains("Depth too deep"), + "Expected a graceful depth-failure ParseException. Actual:\n" + text); + } + + @Test + void a02_moderateNestingStillParses() throws Exception { + // 10 levels of nesting -> well within the depth budget; legitimate shallow payloads must be + // unaffected by the new guard. + var msg = nestedMessage(10); + var b = ProtobufParser.DEFAULT.read(msg, Nested.class); + var cur = b; + for (var i = 0; i < 10; i++) { + assertNotNull(cur.child); + cur = cur.child; + } + assertNull(cur.child); + } +}
