Skip to content

Commit bbd083d

Browse files
SEC: Apply MAX_DECLARED_STREAM_LENGTH to streams without length as well (#3871)
1 parent d5cd266 commit bbd083d

7 files changed

Lines changed: 177 additions & 40 deletions

File tree

pypdf/_utils.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
from .errors import (
6262
STREAM_TRUNCATED_PREMATURELY,
6363
DeprecationError,
64+
LimitReachedError,
6465
PdfStreamError,
6566
)
6667

@@ -243,41 +244,49 @@ def skip_over_comment(stream: StreamType) -> None:
243244
raise PdfStreamError("File ended unexpectedly.")
244245

245246

246-
def read_until_regex(stream: StreamType, regex: Pattern[bytes]) -> bytes:
247+
def read_until_regex(*, stream: StreamType, regex: Pattern[bytes], length: int = sys.maxsize) -> bytes:
247248
"""
248249
Read until the regular expression pattern matched (ignore the match).
249250
Treats EOF on the underlying stream as the end of the token to be matched.
250251
251252
Args:
252-
regex: re.Pattern
253+
stream: The stream to read from.
254+
regex: The pattern to search for.
255+
length: The (approximated) maximum number of bytes to read before raising an exception.
253256
254257
Returns:
255258
The read bytes.
256259
257260
"""
258261
parts: list[bytes] = []
259-
total_len = 0
262+
total_length = 0
260263
tail = b""
261264
chunk_size = 16
262265
while True:
263-
tok = stream.read(chunk_size)
264-
if not tok:
266+
token = stream.read(chunk_size)
267+
if not token:
265268
return b"".join(parts)
269+
token_length = len(token)
270+
if (current_length := total_length + token_length) >= length:
271+
raise LimitReachedError(
272+
f"Read stream length of {current_length} exceeds maximum allowed length of {length}."
273+
)
274+
266275
# Search overlap of previous tail + new chunk to catch
267276
# multi-byte regex matches spanning chunk boundaries.
268-
buf = tail + tok
269-
m = regex.search(buf)
270-
if m is not None:
277+
current_buffer = tail + token
278+
search_match = regex.search(current_buffer)
279+
parts.append(token)
280+
if search_match is not None:
271281
overlap = len(tail)
272-
actual_start = total_len - overlap + m.start()
273-
stream.seek(actual_start - total_len - len(tok), 1)
274-
parts.append(tok)
282+
actual_start = total_length - overlap + search_match.start()
283+
stream.seek(actual_start - total_length - token_length, 1)
275284
return b"".join(parts)[:actual_start]
276-
parts.append(tok)
277-
total_len += len(tok)
285+
total_length += token_length
286+
278287
# Fixed overlap: 16 bytes is sufficient for the short
279288
# delimiter patterns used in PDF parsing.
280-
tail = tok[-16:]
289+
tail = token[-16:]
281290
if chunk_size < 8192:
282291
chunk_size <<= 1
283292

pypdf/generic/_base.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,7 @@ def write_to_stream(
550550

551551
class NumberObject(int, PdfObject):
552552
NumberPattern = re.compile(b"[^+-.0-9]")
553+
_LENGTH_LIMIT = 64
553554

554555
def __new__(cls, value: Any) -> Self:
555556
try:
@@ -594,7 +595,7 @@ def write_to_stream(
594595

595596
@staticmethod
596597
def read_from_stream(stream: StreamType) -> Union["NumberObject", "FloatObject"]:
597-
num = read_until_regex(stream, NumberObject.NumberPattern)
598+
num = read_until_regex(stream=stream, regex=NumberObject.NumberPattern, length=NumberObject._LENGTH_LIMIT)
598599
if b"." in num:
599600
return FloatObject(num)
600601
return NumberObject(num)
@@ -812,6 +813,7 @@ class NameObject(str, PdfObject): # noqa: SLOT000
812813
**{chr(i): f"#{i:02X}".encode() for i in b"#()<>[]{}/%"},
813814
**{chr(i): f"#{i:02X}".encode() for i in range(33)},
814815
}
816+
_LENGTH_LIMIT = 4096
815817

816818
def clone(
817819
self,
@@ -907,7 +909,7 @@ def read_from_stream(stream: StreamType, pdf: Any) -> "NameObject": # PdfReader
907909
name = stream.read(1)
908910
if name != NameObject.prefix:
909911
raise PdfReadError("Name read error")
910-
name += read_until_regex(stream, NameObject.delimiter_pattern)
912+
name += read_until_regex(stream=stream, regex=NameObject.delimiter_pattern, length=NameObject._LENGTH_LIMIT)
911913
try:
912914
# Name objects should represent irregular characters
913915
# with a '#' followed by the symbol's hex number

pypdf/generic/_data_structures.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
__author_email__ = "biziqe@mathieu.fenniak.net"
3131

3232
import logging
33+
import os
3334
import re
3435
import sys
3536
from collections.abc import Iterable, Sequence
@@ -537,22 +538,34 @@ def _get_next_object_position(
537538
) -> int:
538539
out = position_end
539540
for generation in generations:
540-
location = pdf.xref[generation]
541-
values = [x for x in location.values() if position_before < x <= position_end]
542-
if values:
543-
out = min(out, *values)
541+
for x in pdf.xref[generation].values():
542+
if position_before < x <= position_end:
543+
out = min(out, x)
544544
return out
545545

546546
@classmethod
547547
def _read_unsized_from_stream(
548-
cls, stream: BinaryStreamType, pdf: PdfReaderProtocol
548+
cls, *, stream: BinaryStreamType, pdf: PdfReaderProtocol, length: int,
549549
) -> bytes:
550-
object_position = cls._get_next_object_position(
551-
position_before=stream.tell(), position_end=2 ** 32, generations=list(pdf.xref), pdf=pdf
552-
) - 1
553550
current_position = stream.tell()
551+
552+
# Determine stream size.
553+
try:
554+
stream.seek(0, os.SEEK_END)
555+
stream_length = stream.tell()
556+
finally:
557+
stream.seek(current_position)
558+
559+
object_position = cls._get_next_object_position(
560+
position_before=current_position, position_end=stream_length, generations=list(pdf.xref), pdf=pdf
561+
)
562+
563+
bytes_to_read = object_position - current_position
564+
if bytes_to_read >= length:
565+
raise LimitReachedError(f"Requested length of {bytes_to_read} exceeds maximum allowed length.")
566+
554567
# Read until the next object position.
555-
read_value = stream.read(object_position - stream.tell())
568+
read_value = stream.read(bytes_to_read)
556569
endstream_position = read_value.find(b"endstream")
557570
if endstream_position < 0:
558571
raise PdfReadError(
@@ -661,15 +674,16 @@ def read_from_stream(
661674
if length is None: # if the PDF is damaged
662675
length = -1
663676
pstart = stream.tell()
677+
678+
from ..filters import MAX_DECLARED_STREAM_LENGTH # noqa: PLC0415
664679
if length >= 0:
665-
from ..filters import MAX_DECLARED_STREAM_LENGTH # noqa: PLC0415
666680
if length > MAX_DECLARED_STREAM_LENGTH:
667681
raise LimitReachedError(f"Declared stream length of {length} exceeds maximum allowed length.")
668682

669683
data["__streamdata__"] = stream.read(length)
670684
else:
671685
data["__streamdata__"] = read_until_regex(
672-
stream, re.compile(b"endstream")
686+
stream=stream, regex=re.compile(b"endstream"), length=MAX_DECLARED_STREAM_LENGTH,
673687
)
674688
e = read_non_whitespace(stream)
675689
ndstream = stream.read(8)
@@ -688,7 +702,9 @@ def read_from_stream(
688702
data["__streamdata__"] = data["__streamdata__"][:-1]
689703
elif pdf is not None and not pdf.strict:
690704
stream.seek(pstart, 0)
691-
data["__streamdata__"] = DictionaryObject._read_unsized_from_stream(stream, pdf)
705+
data["__streamdata__"] = DictionaryObject._read_unsized_from_stream(
706+
stream=stream, pdf=pdf, length=MAX_DECLARED_STREAM_LENGTH
707+
)
692708
pos = stream.tell()
693709
else:
694710
stream.seek(pos, 0)
@@ -1190,6 +1206,7 @@ class ContentStream(DecodedStreamObject):
11901206
* when .set_data() is called, ._operations is set to None.
11911207
* when .operations is set, ._data is set to None.
11921208
"""
1209+
_OPERATOR_LENGTH_LIMIT = 128
11931210

11941211
def __init__(
11951212
self,
@@ -1349,7 +1366,9 @@ def _parse_content_stream(self, stream: StreamType) -> None:
13491366
break
13501367
stream.seek(-1, 1)
13511368
if peek.isalpha() or peek in (b"'", b'"'):
1352-
operator = read_until_regex(stream, NameObject.delimiter_pattern)
1369+
operator = read_until_regex(
1370+
stream=stream, regex=NameObject.delimiter_pattern, length=self._OPERATOR_LENGTH_LIMIT
1371+
)
13531372
if operator == b"BI":
13541373
# begin inline image - a completely different parsing
13551374
# mechanism is required, of course... thanks buddy...

tests/generic/test_base.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import pytest
55

66
from pypdf import PdfReader, PdfWriter
7-
from pypdf.generic import read_hex_string_from_stream
7+
from pypdf.errors import LimitReachedError
8+
from pypdf.generic import FloatObject, NameObject, NumberObject, read_hex_string_from_stream
89
from tests import get_data_from_url
910

1011

@@ -43,3 +44,27 @@ def test_text_string_object__wrongly_detected_bom() -> None:
4344
"系可论,步各之但\n"
4445
"12"
4546
)
47+
48+
49+
def test_number_object__read_from_stream__limits() -> None:
50+
stream = BytesIO(b"13.37\n")
51+
assert NumberObject.read_from_stream(stream) == FloatObject("13.37")
52+
53+
stream = BytesIO(f"{'1' * 100}\n".encode())
54+
with pytest.raises(
55+
expected_exception=LimitReachedError,
56+
match=r"^Read stream length of 101 exceeds maximum allowed length of 64\.$"
57+
):
58+
NumberObject.read_from_stream(stream)
59+
60+
61+
def test_name_object__read_from_stream__limits() -> None:
62+
stream = BytesIO(b"/My#20Name\n")
63+
assert NameObject.read_from_stream(stream, pdf=None) == NameObject("/My Name")
64+
65+
stream = BytesIO(f"/{'SomeName' * 5000}\n".encode())
66+
with pytest.raises(
67+
expected_exception=LimitReachedError,
68+
match=r"^Read stream length of 8176 exceeds maximum allowed length of 4096\.$"
69+
):
70+
NameObject.read_from_stream(stream, pdf=None)

tests/generic/test_data_structures.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from io import BytesIO
66
from pathlib import Path
77
from typing import Callable
8+
from unittest import mock
89

910
import pytest
1011

@@ -17,6 +18,7 @@
1718
DictionaryObject,
1819
NameObject,
1920
NullObject,
21+
NumberObject,
2022
RectangleObject,
2123
StreamObject,
2224
TextStringObject,
@@ -378,3 +380,81 @@ def test_dictionary_object__get_inherited__cyclic() -> None:
378380
match=r"^Detected cycle in /Parent hierarchy when retrieving value for key '/FT'\.$"
379381
):
380382
writer.get_pages_showing_field(reference1)
383+
384+
385+
def _make_pdf__read_from_stream__limit() -> bytes:
386+
offsets = []
387+
pdf = bytearray(b"%PDF-1.4\n")
388+
389+
def add_obj(n: int, body: bytes) -> None:
390+
offsets.append((n, len(pdf)))
391+
pdf.extend(f"{n} 0 obj\n".encode())
392+
pdf.extend(body)
393+
pdf.extend(b"\nendobj\n")
394+
395+
add_obj(1, b"<< /Type /Catalog /Pages 2 0 R >>")
396+
add_obj(2, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
397+
add_obj(3, b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Contents 4 0 R /Resources << >> >>")
398+
offsets.append((4, len(pdf)))
399+
pdf.extend(b"4 0 obj\n")
400+
pdf.extend(b"<< >>\nstream\n")
401+
pdf.extend(b"A" * 75_000_001)
402+
pdf.extend(b"\nendstream\nendobj\n")
403+
xref_offset = len(pdf)
404+
pdf.extend(b"xref\n0 5\n0000000000 65535 f \n")
405+
offset_map = dict(offsets)
406+
for n in range(1, 5):
407+
pdf.extend(f"{offset_map[n]:010d} 00000 n \n".encode())
408+
pdf.extend(f"trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode())
409+
return bytes(pdf)
410+
411+
412+
@pytest.mark.timeout(5)
413+
def test_dictionary_object__read_from_stream__missing_length__limit() -> None:
414+
reader = PdfReader(BytesIO(_make_pdf__read_from_stream__limit()))
415+
page = reader.pages[0]
416+
417+
with pytest.raises(
418+
expected_exception=LimitReachedError,
419+
match=r"^Read stream length of 75000187 exceeds maximum allowed length of 75000000\.$"
420+
):
421+
page.get_contents()
422+
423+
424+
def test_dictionary_object__read_from_stream__read_unsized__limit() -> None:
425+
reader = PdfReader(RESOURCE_ROOT / "issue-301.pdf")
426+
427+
with mock.patch.object(DictionaryObject, "_read_unsized_from_stream", return_value=b"dummy") as read_mock:
428+
obj = reader.get_object(13)
429+
assert obj is not None
430+
assert obj == DictionaryObject({
431+
NameObject("/Filter"): NameObject("/FlateDecode"),
432+
NameObject("/Length1"): NumberObject(218)
433+
})
434+
read_mock.assert_called_once_with(stream=reader.stream, pdf=reader, length=75_000_000)
435+
436+
437+
def test_dictionary_object__read_unsized_from_stream__limit() -> None:
438+
reader = PdfReader(RESOURCE_ROOT / "issue-301.pdf")
439+
440+
reader.stream.seek(63101, 0) # pstart value from the corresponding call
441+
with pytest.raises(
442+
expected_exception=LimitReachedError,
443+
match=r"^Requested length of 236 exceeds maximum allowed length\.$"
444+
):
445+
DictionaryObject._read_unsized_from_stream(stream=reader.stream, pdf=reader, length=137)
446+
447+
448+
def test_content_stream__parse_content_stream__limits() -> None:
449+
content_stream = ContentStream(stream=None, pdf=None)
450+
451+
stream = BytesIO(b"/Do TESTING\n")
452+
content_stream._parse_content_stream(stream)
453+
assert content_stream.operations == [(["/Do"], b"TESTING")]
454+
455+
stream = BytesIO(f"/Do {'TESTING' * 100}\n".encode())
456+
with pytest.raises(
457+
expected_exception=LimitReachedError,
458+
match=r"^Read stream length of 240 exceeds maximum allowed length of 128\.$"
459+
):
460+
content_stream._parse_content_stream(stream)

tests/test_reader.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2052,7 +2052,9 @@ def test_issue2886(caplog):
20522052
url = "https://github.com/user-attachments/files/17187711/crash-e8a85d82de01cab5eb44e7993304d8b9d1544970.pdf"
20532053
name = "issue2886.pdf"
20542054

2055-
with pytest.raises(PdfReadError, match=r"Unexpected empty line in Xref table\."):
2055+
# Actual: 100_067
2056+
with mock.patch("pypdf.generic._base.NumberObject._LENGTH_LIMIT", 200_000), \
2057+
pytest.raises(PdfReadError, match=r"Unexpected empty line in Xref table\."):
20562058
_ = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
20572059

20582060

0 commit comments

Comments
 (0)