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);

Reply via email to