This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 298144e5be TIKA-4878: never treat a declared Content-Length as a 
measurement (#3131)
298144e5be is described below

commit 298144e5be4012dfe83f82e764f6820d5cf9cdfc
Author: Tim Allison <[email protected]>
AuthorDate: Fri Sep 4 14:07:14 2026 -0400

    TIKA-4878: never treat a declared Content-Length as a measurement (#3131)
---
 CHANGES.txt                                        |  7 ++++++
 .../java/org/apache/tika/io/ReopenableSource.java  | 16 +++++--------
 .../org/apache/tika/sax/SecureContentHandler.java  |  4 +++-
 .../org/apache/tika/io/ReopenableSourceTest.java   | 23 ++++++++++++++++++
 .../apache/tika/sax/SecureContentHandlerTest.java  | 28 ++++++++++++++++++++++
 5 files changed, 67 insertions(+), 11 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 8c2568710d..f1a0032763 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,12 @@
 Release 4.1.0 - unreleased
 
+   * A declared Content-Length is no longer treated as a measurement: the
+     zip-bomb ratio counts only measured input bytes (a container-declared
+     size on an embedded document could inflate its denominator), and a
+     re-openable source no longer reserves cache budget or sizes its buffer
+     from the declared length (a lying one could push a small payload to
+     disk or churn the shared budget). Neither is in a release: the
+     exposure arrived with TIKA-4868 and TIKA-4873 (TIKA-4878).
    * Embedded objects in Office documents are re-opened from their container
      instead of cached: every OOXML part (pictures, media, attachments), the
      OLE 2.0 package inside an OOXML part, the CONTENTS entry of an OLE 2.0
diff --git a/tika-core/src/main/java/org/apache/tika/io/ReopenableSource.java 
b/tika-core/src/main/java/org/apache/tika/io/ReopenableSource.java
index 8cbaa66881..61c8dcf181 100644
--- a/tika-core/src/main/java/org/apache/tika/io/ReopenableSource.java
+++ b/tika-core/src/main/java/org/apache/tika/io/ReopenableSource.java
@@ -221,8 +221,11 @@ class ReopenableSource extends InputStream implements 
TikaInputSource {
     /**
      * Drains a fresh stream into memory if it fits within the per-object 
floor plus what
      * can be reserved from the shared budget, retaining the buffer (and its 
reservation)
-     * until {@link #close()}. The declared length is a sizing hint only -- it 
can lie, so
-     * the cap is enforced during the read. Does not disturb this source's 
read position.
+     * until {@link #close()}. The declared length is the file's claim: it may 
skip an
+     * attempt that cannot succeed (a lie there costs a spill, nothing more), 
but it never
+     * sizes a reservation or an allocation -- the buffer starts at the floor 
at most and
+     * grows, reserving, on what is actually read. Does not disturb this 
source's read
+     * position.
      */
     private boolean tryBufferInMemory() throws IOException {
         if (length > MAX_ARRAY_SIZE || (length > IN_MEMORY_FLOOR && budget == 
null)) {
@@ -230,14 +233,7 @@ class ReopenableSource extends InputStream implements 
TikaInputSource {
         }
         long reservedHere = 0;
         // Reservation invariant: reservedHere == max(0, data.length - 
IN_MEMORY_FLOOR)
-        if (length > IN_MEMORY_FLOOR) {
-            long extra = length - IN_MEMORY_FLOOR;
-            if (budget.tryReserve(extra) != extra) {
-                return false;
-            }
-            reservedHere = extra;
-        }
-        byte[] data = new byte[length > 0 ? (int) length : 8192];
+        byte[] data = new byte[(int) Math.max(8192, Math.min(length, 
IN_MEMORY_FLOOR))];
         int total = 0;
         boolean fits = false;
         try (InputStream in = opener.get()) {
diff --git 
a/tika-core/src/main/java/org/apache/tika/sax/SecureContentHandler.java 
b/tika-core/src/main/java/org/apache/tika/sax/SecureContentHandler.java
index 396f0765d6..d871342774 100644
--- a/tika-core/src/main/java/org/apache/tika/sax/SecureContentHandler.java
+++ b/tika-core/src/main/java/org/apache/tika/sax/SecureContentHandler.java
@@ -212,7 +212,9 @@ public class SecureContentHandler extends 
ContentHandlerDecorator {
 
     private long getByteCount() throws SAXException {
         try {
-            if (stream.hasLength()) {
+            //a declared Content-Length is the file's claim; only a measured 
length
+            //or the bytes actually read can serve as the ratio's denominator
+            if (stream.hasReliableLength()) {
                 return stream.getLength();
             } else {
                 return stream.getPosition();
diff --git 
a/tika-core/src/test/java/org/apache/tika/io/ReopenableSourceTest.java 
b/tika-core/src/test/java/org/apache/tika/io/ReopenableSourceTest.java
index d3142dd461..35ab3027be 100644
--- a/tika-core/src/test/java/org/apache/tika/io/ReopenableSourceTest.java
+++ b/tika-core/src/test/java/org/apache/tika/io/ReopenableSourceTest.java
@@ -245,6 +245,29 @@ public class ReopenableSourceTest {
         }
     }
 
+    /**
+     * A declared length far above the content, against a budget that could 
never
+     * grant it: the claim must not be what gets reserved, or a 500-byte 
payload is
+     * pushed to disk by a number the file made up.
+     */
+    @Test
+    public void testLyingDeclaredLengthDoesNotReserveOrSpill() throws 
Exception {
+        byte[] data = data(500);
+        AtomicInteger opens = new AtomicInteger();
+        CacheMemoryBudget budget = new CacheMemoryBudget(1024);
+        try (ReopenableSource source = new 
ReopenableSource(countingOpener(data, opens), tmp,
+                50L * 1024 * 1024, null)) {
+            source.enableRewind(budget);
+            try (SeekableByteChannel channel = 
source.getSeekableByteChannel()) {
+                assertInstanceOf(MemorySeekableByteChannel.class, channel);
+                assertArrayEquals(data, readFully(channel));
+            }
+            assertFalse(source.hasPath());
+            assertEquals(0, budget.getReservedBytes(), "nothing reserved for a 
500 byte payload");
+            assertEquals(500, source.getLength());
+        }
+    }
+
     @Test
     public void testLyingDeclaredLengthCorrected() throws Exception {
         byte[] data = data(500);
diff --git 
a/tika-core/src/test/java/org/apache/tika/sax/SecureContentHandlerTest.java 
b/tika-core/src/test/java/org/apache/tika/sax/SecureContentHandlerTest.java
index 659bc94a5e..6a4f624515 100644
--- a/tika-core/src/test/java/org/apache/tika/sax/SecureContentHandlerTest.java
+++ b/tika-core/src/test/java/org/apache/tika/sax/SecureContentHandlerTest.java
@@ -29,7 +29,10 @@ import org.xml.sax.helpers.AttributesImpl;
 import org.xml.sax.helpers.DefaultHandler;
 
 import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
 
 /**
  * Tests for the {@link SecureContentHandler} class.
@@ -108,6 +111,31 @@ public class SecureContentHandlerTest {
         }
     }
 
+    /**
+     * A one-shot stream carrying a declared Content-Length far above what it 
holds:
+     * the claim must not become the input byte count, or the ratio never 
trips.
+     */
+    @Test
+    public void testDeclaredLengthDoesNotInflateByteCount() throws IOException 
{
+        Metadata metadata = new Metadata();
+        //a plausible lie: large enough that no ratio trips, small enough that
+        //byteCount * ratio does not overflow and trip for the wrong reason
+        metadata.set(HttpHeaders.CONTENT_LENGTH, Long.toString(100L * 1024 * 
1024 * 1024));
+        try (TikaInputStream lying = TikaInputStream.get(new 
NullInputStream(MANY_BYTES),
+                new TemporaryResources(), metadata)) {
+            SecureContentHandler lyingHandler =
+                    new SecureContentHandler(new DefaultHandler(), lying);
+            char[] ch = new char[1000];
+            for (int i = 0; i < MANY_BYTES; i++) {
+                lying.read();
+                lyingHandler.characters(ch, 0, ch.length);
+            }
+            fail("Expected SAXException not thrown");
+        } catch (SAXException e) {
+            // expected
+        }
+    }
+
     @Test
     public void testSomeCharactersWithoutInput() throws IOException {
         try {

Reply via email to