Skip to content

Commit 3ce06e2

Browse files
authored
Merge pull request #3 from ActiveState/1.28.6-sec-generic
Security (3/5): generic.py parse limits & cycle guard — CVE-2026-27024, CVE-2026-31826, CVE-2026-33123
2 parents 9eb1b88 + bfd5b6c commit 3ce06e2

2 files changed

Lines changed: 164 additions & 4 deletions

File tree

PyPDF2/generic.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@
6363
from io import BytesIO, StringIO
6464

6565
logger = logging.getLogger(__name__)
66+
67+
# CVE-2026-31826: refuse to pre-allocate a read buffer for an absurd declared
68+
# stream /Length. Set to 0 to disable (only safe for fully trusted input).
69+
MAX_DECLARED_STREAM_LENGTH = 75000000 # 75 MB
70+
71+
# CVE-2026-33123: bound array-based content streams. Set to 0 to disable.
72+
CONTENT_STREAM_ARRAY_MAX_LENGTH = 10000 # max number of array elements
73+
MAX_ARRAY_BASED_STREAM_OUTPUT_LENGTH = 75000000 # 75 MB total concatenated
74+
6675
ObjectPrefix = b_("/<[tf(n%")
6776
NumberSigns = b_("+-")
6877
IndirectPattern = re.compile(b_(r"[+-]?(\d+)\s+(\d+)\s+R[^a-zA-Z]"))
@@ -835,6 +844,17 @@ def read_from_stream(stream, pdf):
835844
t = stream.tell()
836845
length = pdf.get_object(length)
837846
stream.seek(t, 0)
847+
# CVE-2026-31826: a crafted /Length (e.g. 2 GB) would make
848+
# stream.read(length) pre-allocate a huge buffer. Reject it.
849+
if (
850+
isinstance(length, int)
851+
and MAX_DECLARED_STREAM_LENGTH
852+
and length > MAX_DECLARED_STREAM_LENGTH
853+
):
854+
raise PdfReadError(
855+
"Declared stream length (%d bytes) exceeds maximum allowed "
856+
"(%d bytes)." % (length, MAX_DECLARED_STREAM_LENGTH)
857+
)
838858
data["__streamdata__"] = stream.read(length)
839859
e = readNonWhitespace(stream)
840860
ndstream = stream.read(8)
@@ -895,9 +915,22 @@ def children(self):
895915
raise StopIteration
896916

897917
child = self["/First"]
918+
last = self["/Last"]
919+
# CVE-2026-27024: a crafted outline whose /Next chain forms a cycle
920+
# that never reaches /Last made this loop run forever. Track visited
921+
# nodes and stop on a repeat.
922+
visited = set()
898923
while True:
924+
child_id = id(child)
925+
if child_id in visited:
926+
logger.warning("Cycle detected in TreeObject.children; stopping")
927+
if sys.version_info >= (3, 5): # PEP 479
928+
return
929+
else:
930+
raise StopIteration
931+
visited.add(child_id)
899932
yield child
900-
if child == self["/Last"]:
933+
if child == last:
901934
if sys.version_info >= (3, 5): # PEP 479
902935
return
903936
else:
@@ -1191,10 +1224,32 @@ def __init__(self, stream, pdf):
11911224
# multiple StreamObjects to be cat'd together.
11921225
stream = stream.get_object()
11931226
if isinstance(stream, ArrayObject):
1194-
data = b_("")
1227+
# CVE-2026-33123: bound both the number of array elements and the
1228+
# total concatenated size so a crafted array-based content stream
1229+
# cannot exhaust CPU/memory.
1230+
if (
1231+
CONTENT_STREAM_ARRAY_MAX_LENGTH
1232+
and len(stream) > CONTENT_STREAM_ARRAY_MAX_LENGTH
1233+
):
1234+
raise PdfReadError(
1235+
"Content stream array has %d elements, exceeding the "
1236+
"maximum of %d." % (len(stream), CONTENT_STREAM_ARRAY_MAX_LENGTH)
1237+
)
1238+
parts = []
1239+
total = 0
11951240
for s in stream:
1196-
data += b_(s.get_object().get_data())
1197-
stream = BytesIO(b_(data))
1241+
new_data = b_(s.get_object().get_data())
1242+
total += len(new_data)
1243+
if (
1244+
MAX_ARRAY_BASED_STREAM_OUTPUT_LENGTH
1245+
and total > MAX_ARRAY_BASED_STREAM_OUTPUT_LENGTH
1246+
):
1247+
raise PdfReadError(
1248+
"Content stream array output exceeds the maximum of "
1249+
"%d bytes." % MAX_ARRAY_BASED_STREAM_OUTPUT_LENGTH
1250+
)
1251+
parts.append(new_data)
1252+
stream = BytesIO(b_("").join(parts))
11981253
else:
11991254
stream = BytesIO(b_(stream.get_data()))
12001255
self.__parseContentStream(stream)

Tests/test_security_generic.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Regression tests for the generic.py parsing-limit / cycle-guard backports:
4+
5+
- CVE-2026-27024 : TreeObject.children cyclic /Next infinite loop.
6+
- CVE-2026-31826 : unbounded declared stream /Length allocation.
7+
- CVE-2026-33123 : array-based ContentStream resource exhaustion.
8+
"""
9+
import pytest
10+
11+
try:
12+
from io import BytesIO
13+
except ImportError: # pragma: no cover
14+
from cStringIO import StringIO as BytesIO
15+
try:
16+
from unittest.mock import Mock
17+
except ImportError: # Python 2
18+
from mock import Mock
19+
20+
from PyPDF2 import generic
21+
from PyPDF2.errors import PdfReadError
22+
from PyPDF2.generic import (
23+
ArrayObject,
24+
ContentStream,
25+
DecodedStreamObject,
26+
DictionaryObject,
27+
NameObject,
28+
TreeObject,
29+
)
30+
31+
32+
def _stream(data):
33+
so = DecodedStreamObject()
34+
so._data = data
35+
return so
36+
37+
38+
def _node(**kv):
39+
d = DictionaryObject()
40+
for k, v in kv.items():
41+
d[NameObject(k)] = v
42+
return d
43+
44+
45+
# --- CVE-2026-27024: TreeObject.children cycle ----------------------------
46+
47+
def test_children_linear_ok():
48+
a = DictionaryObject()
49+
b = DictionaryObject()
50+
a[NameObject("/Next")] = b
51+
t = TreeObject()
52+
t[NameObject("/First")] = a
53+
t[NameObject("/Last")] = b
54+
assert list(t.children()) == [a, b]
55+
56+
57+
def test_children_cycle_terminates():
58+
a = DictionaryObject()
59+
b = DictionaryObject()
60+
a[NameObject("/Next")] = b
61+
b[NameObject("/Next")] = a # cycle that never reaches /Last
62+
t = TreeObject()
63+
t[NameObject("/First")] = a
64+
t[NameObject("/Last")] = DictionaryObject() # unreachable sentinel
65+
kids = list(t.children()) # must terminate rather than hang
66+
assert kids == [a, b]
67+
68+
69+
# --- CVE-2026-31826: declared stream /Length cap --------------------------
70+
71+
def test_declared_stream_length_capped():
72+
pdf = Mock(strict=False)
73+
data = b"<< /Length 999999999 >>\nstream\n"
74+
with pytest.raises(PdfReadError):
75+
generic.DictionaryObject.read_from_stream(BytesIO(data), pdf)
76+
77+
78+
def test_small_stream_length_ok():
79+
pdf = Mock(strict=False)
80+
data = b"<< /Length 3 >>\nstream\nabc\nendstream"
81+
obj = generic.DictionaryObject.read_from_stream(BytesIO(data), pdf)
82+
assert obj.get_data() in (b"abc", "abc")
83+
84+
85+
# --- CVE-2026-33123: array-based ContentStream caps -----------------------
86+
87+
def test_content_stream_array_element_cap(monkeypatch):
88+
monkeypatch.setattr(generic, "CONTENT_STREAM_ARRAY_MAX_LENGTH", 2)
89+
arr = ArrayObject([_stream(b"q\n"), _stream(b"Q\n"), _stream(b"q\n")])
90+
with pytest.raises(PdfReadError):
91+
ContentStream(arr, Mock())
92+
93+
94+
def test_content_stream_array_output_cap(monkeypatch):
95+
monkeypatch.setattr(generic, "MAX_ARRAY_BASED_STREAM_OUTPUT_LENGTH", 3)
96+
arr = ArrayObject([_stream(b"q\n"), _stream(b"Q\n")]) # 4 bytes > 3
97+
with pytest.raises(PdfReadError):
98+
ContentStream(arr, Mock())
99+
100+
101+
def test_content_stream_array_ok():
102+
arr = ArrayObject([_stream(b"q\n"), _stream(b"Q\n")])
103+
cs = ContentStream(arr, Mock())
104+
assert len(cs.operations) == 2
105+

0 commit comments

Comments
 (0)