From cf426f7fffaffa8a621c5d108c0e4ea218536653 Mon Sep 17 00:00:00 2001
From: stefan6419846 <96178532+stefan6419846@users.noreply.github.com>
Date: Mon, 11 Aug 2025 15:46:37 +0200
Subject: [PATCH 1/2] SEC: Limit decompressed size for FlateDecode filter

Closes #3429.
---
 pypdf/_reader.py         | 10 ++++-
 pypdf/errors.py          |  4 ++
 pypdf/filters.py         | 89 +++++++++++++++++++++++++---------------
 tests/example_files.yaml |  2 +
 tests/test_filters.py    | 41 ++++++++++++++----
 tests/test_reader.py     | 15 +++++++
 6 files changed, 119 insertions(+), 42 deletions(-)

Index: pypdf-4.3.1/pypdf/_reader.py
===================================================================
--- pypdf-4.3.1.orig/pypdf/_reader.py
+++ pypdf-4.3.1/pypdf/_reader.py
@@ -932,15 +932,20 @@ class PdfReader(PdfDocCommon):
         xrefstream = cast(ContentStream, read_object(stream, self))
         assert cast(str, xrefstream["/Type"]) == "/XRef"
         self.cache_indirect_object(generation, idnum, xrefstream)
-        stream_data = BytesIO(b_(xrefstream.get_data()))
+
         # Index pairs specify the subsections in the dictionary. If
         # none create one subsection that spans everything.
-        idx_pairs = xrefstream.get("/Index", [0, xrefstream.get("/Size")])
+        if "/Size" not in xrefstream:
+            # According to table 17 of the PDF 2.0 specification, this key is required.
+            raise PdfReadError(f"Size missing from XRef stream {xrefstream!r}!")
+        idx_pairs = xrefstream.get("/Index", [0, xrefstream["/Size"]])
         entry_sizes = cast(Dict[Any, Any], xrefstream.get("/W"))
         assert len(entry_sizes) >= 3
         if self.strict and len(entry_sizes) > 3:
             raise PdfReadError(f"Too many entry sizes: {entry_sizes}")
 
+        stream_data = BytesIO(xrefstream.get_data())
+
         def get_entry(i: int) -> Union[int, Tuple[int, ...]]:
             # Reads the correct number of bytes for each entry. See the
             # discussion of the W parameter in PDF spec table 17.
Index: pypdf-4.3.1/pypdf/errors.py
===================================================================
--- pypdf-4.3.1.orig/pypdf/errors.py
+++ pypdf-4.3.1/pypdf/errors.py
@@ -60,3 +60,7 @@ class EmptyFileError(PdfReadError):
 
 
 STREAM_TRUNCATED_PREMATURELY = "Stream has ended unexpectedly"
+
+
+class LimitReachedError(PyPdfError):
+    """Raised when a limit is reached."""
Index: pypdf-4.3.1/pypdf/filters.py
===================================================================
--- pypdf-4.3.1.orig/pypdf/filters.py
+++ pypdf-4.3.1/pypdf/filters.py
@@ -56,7 +56,7 @@ from .constants import FilterTypes as FT
 from .constants import ImageAttributes as IA
 from .constants import LzwFilterParameters as LZW
 from .constants import StreamAttributes as SA
-from .errors import DeprecationError, PdfReadError, PdfStreamError
+from .errors import DeprecationError, LimitReachedError, PdfReadError, PdfStreamError
 from .generic import (
     ArrayObject,
     DictionaryObject,
@@ -64,6 +64,18 @@ from .generic import (
     NullObject,
 )
 
+ZLIB_MAX_OUTPUT_LENGTH = 75_000_000
+
+
+def _decompress_with_limit(data: bytes) -> bytes:
+    decompressor = zlib.decompressobj()
+    result = decompressor.decompress(data, max_length=ZLIB_MAX_OUTPUT_LENGTH)
+    if decompressor.unconsumed_tail:
+        raise LimitReachedError(
+            f"Limit reached while decompressing. {len(decompressor.unconsumed_tail)} bytes remaining."
+        )
+    return result
+
 
 def decompress(data: bytes) -> bytes:
     """
@@ -73,6 +85,12 @@ def decompress(data: bytes) -> bytes:
     decompression fails due to a zlib error, it falls back to using a
     decompression object with a larger window size.
 
+    Please note that the output length is limited to avoid memory
+    issues. If you need to process larger content streams, consider
+    adapting ``pypdf.filters.ZLIB_MAX_OUTPUT_LENGTH``. In case you
+    are only dealing with trusted inputs and/or want to disable these
+    limits, set the value to `0`.
+
     Args:
         data: The input data to be decompressed.
 
@@ -80,21 +98,43 @@ def decompress(data: bytes) -> bytes:
         The decompressed data.
     """
     try:
-        return zlib.decompress(data)
+        return _decompress_with_limit(data)
     except zlib.error:
-        try:
-            # For larger files, use Decompress object to enable buffered reading
-            return zlib.decompressobj().decompress(data)
-        except zlib.error:
-            # If still failed, then try with increased window size
-            d = zlib.decompressobj(zlib.MAX_WBITS | 32)
-            result_str = b""
-            for b in [data[i : i + 1] for i in range(len(data))]:
-                try:
-                    result_str += d.decompress(b)
-                except zlib.error:
-                    pass
-            return result_str
+        # First quick approach: There are known issues with faulty added bytes to the
+        # tail of the encoded stream from early Adobe Distiller or Pitstop versions
+        # with CR char as the default line separator (assumed by reverse engineering)
+        # that breaks the decoding process in the end.
+        #
+        # Try first to cut off some of the tail byte by byte, but limited to not
+        # iterate through too many loops and kill the performance for large streams,
+        # to then allow the final fallback to run. Added this intermediate attempt,
+        # because starting from the head of the stream byte by byte kills completely
+        # the performance for large streams (e.g., 6 MB) with the tail-byte-issue
+        # and takes ages. This solution is really fast:
+        max_tail_cut_off_bytes: int = 8
+        for i in range(1, min(max_tail_cut_off_bytes + 1, len(data))):
+            try:
+                return _decompress_with_limit(data[:-i])
+            except zlib.error:
+                pass
+
+        # If still failing, then try with increased window size.
+        decompressor = zlib.decompressobj(zlib.MAX_WBITS | 32)
+        result_str = b""
+        remaining_limit = ZLIB_MAX_OUTPUT_LENGTH
+        data_single_bytes = [data[i : i + 1] for i in range(len(data))]
+        for index, b in enumerate(data_single_bytes):
+            try:
+                decompressed = decompressor.decompress(b, max_length=remaining_limit)
+                result_str += decompressed
+                remaining_limit -= len(decompressed)
+                if remaining_limit <= 0:
+                    raise LimitReachedError(
+                        f"Limit reached while decompressing. {len(data_single_bytes) - index} bytes remaining."
+                    )
+            except zlib.error:
+                pass
+        return result_str
 
 
 class FlateDecode:
@@ -679,7 +719,7 @@ def decode_stream_data(stream: Any) -> U
     if not isinstance(decodparms, (list, tuple)):
         decodparms = (decodparms,)
     data: bytes = b_(stream._data)
-    # If there is not data to decode we should not try to decode the data.
+    # If there is not data to decode, we should not try to decode it.
     if data:
         for filter_type, params in zip(filters, decodparms):
             if isinstance(params, NullObject):
