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 18e68d819d699d3cd950784cbf25297cd470113d Author: James Bognar <[email protected]> AuthorDate: Sun Aug 16 15:32:36 2026 -0400 READY-387: Enforce declared document-length bounds in the BSON parser --- .../juneau/marshall/bson/BsonInputStream.java | 82 ++++++++++++++++++++-- .../juneau/marshall/bson/BsonInputStream_Test.java | 68 ++++++++++++++++++ .../marshall/bson/BsonParserSession_Test.java | 5 +- 3 files changed, 146 insertions(+), 9 deletions(-) diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/bson/BsonInputStream.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/bson/BsonInputStream.java index ea8e698132..e6e5aac726 100644 --- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/bson/BsonInputStream.java +++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/bson/BsonInputStream.java @@ -21,6 +21,7 @@ import static org.apache.juneau.commons.utils.Shorts.*; import java.io.*; import java.math.*; import java.nio.charset.*; +import java.util.*; import org.apache.juneau.marshall.parser.*; @@ -49,6 +50,12 @@ public class BsonInputStream extends ParserInputStream { private int pushback = -1; + // Stack of remaining-byte counts, one per currently-open document/array (innermost on top). Every + // physical byte pulled off the wire decrements *all* active levels, since bytes consumed by a nested + // document/array also count against its enclosing document's declared length. This is what stops a + // forged-short document length plus an unterminated cstring from growing past the declared bound. + private final Deque<int[]> bounds = new ArrayDeque<>(); + /** * Constructor. * @@ -66,7 +73,11 @@ public class BsonInputStream extends ParserInputStream { pushback = -1; return b; } - return super.read(); + checkBoundAvailable(); + var b = super.read(); + if (b >= 0) + decrementBounds(); + return b; } /** @@ -76,6 +87,48 @@ public class BsonInputStream extends ParserInputStream { pushback = b & 0xFF; } + /** + * Verifies every currently-open document/array bound still has at least one byte of budget left, + * before a further physical byte is pulled off the wire. + */ + private void checkBoundAvailable() throws IOException { + for (var bound : bounds) + if (bound[0] <= 0) + throw ioex("BSON document declared length exceeded"); + } + + /** + * Charges one byte against every currently-open document/array bound. + */ + private void decrementBounds() { + for (var bound : bounds) + bound[0]--; + } + + /** + * Opens a length-bounded scope for a document/array body being entered, so cstrings and nested values + * cannot be read past its declared length regardless of what the enclosing bound(s) still allow. + * + * @param remaining The number of bytes remaining in the body (declared size minus the 4-byte length + * header already consumed). + */ + private void pushDocumentBound(int remaining) { + bounds.push(new int[]{remaining}); + } + + /** + * Closes the innermost document/array bound after its terminator has been consumed. + * + * @throws IOException If the declared length was not fully consumed (unconsumed trailing bytes). + */ + private void popDocumentBound() throws IOException { + if (bounds.isEmpty()) + return; + var remaining = bounds.pop()[0]; + if (remaining != 0) + throw ioex("BSON document declared length not fully consumed (%s byte(s) remaining)", remaining); + } + /** * Reads a 4-byte little-endian integer. * @@ -105,13 +158,17 @@ public class BsonInputStream extends ParserInputStream { } /** - * Reads the document size (int32) from the stream. + * Reads the document size (int32) from the stream and opens a length-bounded scope for the document + * body that follows, so a forged-short declared length cannot be bypassed by traversal that otherwise + * relies solely on finding a {@code 0x00} terminator. * * @return The document size in bytes. * @throws IOException If the stream ends prematurely. */ public int readDocumentSize() throws IOException { - return checkLength(readLE4(), "document"); + var size = checkLength(readLE4(), "document"); + pushDocumentBound(size - 4); + return size; } /** @@ -140,14 +197,21 @@ public class BsonInputStream extends ParserInputStream { /** * Reads a cstring (UTF-8 bytes until 0x00). * + * <p> + * Growth is capped by whichever is tighter of the innermost open document/array bound (enforced by + * {@link #read()}) or the configured {@link #setMaxLength(int) maxLength}, so an unterminated cstring + * cannot grow the backing buffer without limit. + * * @return The string value. - * @throws IOException If the stream ends prematurely. + * @throws IOException If the stream ends prematurely or the cstring exceeds the configured maximum length. */ public String readCString() throws IOException { var baos = new ByteArrayOutputStream(); int b; - while ((b = read()) >= 0 && b != 0) + while ((b = read()) >= 0 && b != 0) { + checkLength(baos.size() + 1, "cstring"); baos.write(b); + } if (b < 0) throw ioex(UNEXPECTED_END_OF_BSON_STREAM); return new String(baos.toByteArray(), UTF8); @@ -167,14 +231,18 @@ public class BsonInputStream extends ParserInputStream { } /** - * Consumes the document terminator byte (0x00). + * Consumes the document terminator byte (0x00) and closes the length-bounded scope opened by the + * matching {@link #readDocumentSize()}, rejecting trailing bytes left unconsumed within the declared + * length. * - * @throws IOException If the stream ends prematurely or byte is not 0x00. + * @throws IOException If the stream ends prematurely, the byte is not 0x00, or the declared document + * length was not fully consumed. */ public void readDocumentTerminator() throws IOException { var b = read(); if (b != 0x00) throw ioex("Expected document terminator, got %s", b); + popDocumentBound(); } /** diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/bson/BsonInputStream_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/bson/BsonInputStream_Test.java index d757cdd62b..2609eb398e 100644 --- a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/bson/BsonInputStream_Test.java +++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/bson/BsonInputStream_Test.java @@ -366,6 +366,74 @@ class BsonInputStream_Test extends TestBase { } } + // ==================================================================== + // Document/array declared-length bound enforcement (forged-header protection; see b05_readDocumentSize) + // ==================================================================== + + @Test + void b31_readDocumentSize_forgedShortBoundRejectsUnterminatedCString() throws Exception { + // Forged-short document length (5 = 4-byte header + 1 remaining body byte) followed by a long run + // of non-null bytes simulating an attacker-controlled unterminated cstring. Before the fix, traversal + // relied solely on finding 0x00 and would grow the buffer across the entire stream. + var unterminated = new byte[10_000]; + Arrays.fill(unterminated, (byte) 'a'); + try (var is = openIs(cat(le4(5), unterminated))) { + is.readDocumentSize(); + var ex = assertThrows(IOException.class, is::readCString); + assertTrue(ex.getMessage().contains("declared length exceeded")); + } + } + + @Test + void b32_readDocumentSize_wellFormedDocumentBoundedReadSucceeds() throws Exception { + // Declared size exactly matches the payload: bound enforcement must not reject the well-formed case. + var body = cat(cstring("ok"), new byte[]{0x00}); + var doc = cat(le4(body.length + 4), body); + try (var is = openIs(doc)) { + is.readDocumentSize(); + assertEquals("ok", is.readCString()); + is.readDocumentTerminator(); + } + } + + @Test + void b33_readDocumentSize_nestedDocumentHonorsOwnBound() throws Exception { + // Outer document has ample remaining budget; a nested document's own forged-short declared length + // must still bound its cstring reads independently of (and tighter than) the outer bound. + var unterminated = new byte[1_000]; + Arrays.fill(unterminated, (byte) 'x'); + var outerBody = cat(le4(5), unterminated); // nested doc header (size=5) + long unterminated payload + var outer = cat(le4(outerBody.length + 4), outerBody); + try (var is = openIs(outer)) { + is.readDocumentSize(); // outer bound: ~1000 bytes remaining + is.readDocumentSize(); // nested bound: 1 byte remaining + var ex = assertThrows(IOException.class, is::readCString); + assertTrue(ex.getMessage().contains("declared length exceeded")); + } + } + + @Test + void b34_readDocumentTerminator_trailingUnconsumedBytesRejected() throws Exception { + // Declared size promises more body bytes than are actually consumed before the terminator arrives. + var bytes = cat(le4(20), new byte[]{0x00}); // declares 16 body bytes, but terminator arrives immediately + try (var is = openIs(bytes)) { + is.readDocumentSize(); + assertTrue(is.isDocumentEnd()); + var ex = assertThrows(IOException.class, is::readDocumentTerminator); + assertTrue(ex.getMessage().contains("not fully consumed")); + } + } + + @Test + void b35_readDocumentSize_eofBeforeDeclaredSizeConsumedRejected() throws Exception { + // Stream ends before the declared document length is fully consumed. + var bytes = le4(100); // declares 96 body bytes, but the stream provides none + try (var is = openIs(bytes)) { + is.readDocumentSize(); + assertThrows(IOException.class, is::readElementType); + } + } + // ==================================================================== // Pushback mechanism (read after pushback) // ==================================================================== diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/bson/BsonParserSession_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/bson/BsonParserSession_Test.java index 3765d87480..8003be7511 100644 --- a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/bson/BsonParserSession_Test.java +++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/bson/BsonParserSession_Test.java @@ -712,8 +712,9 @@ class BsonParserSession_Test extends TestBase { var nameValue = cat(le4("n".getBytes(StandardCharsets.UTF_8).length + 1), cstring("n")); var body = cat( new byte[]{0x0A}, cstring("_type"), - new byte[]{0x02}, cstring("name"), nameValue); - var bytes = cat(le4(body.length + 4), body, new byte[]{0x00}); + new byte[]{0x02}, cstring("name"), nameValue, + new byte[]{0x00}); + var bytes = cat(le4(body.length + 4), body); var result = BsonParser.DEFAULT.read(bytes, G02_Bean.class); assertNotNull(result); assertEquals("n", result.name);
