Skip to content

Commit d807a0e

Browse files
authored
Merge pull request #5 from ActiveState/1.28.6-sec-reader-outline-xmp
Security (5/5): outline cycle + XMP entity expansion — CVE-2026-24688, CVE-2026-40260
2 parents 2219293 + a4a1a39 commit d807a0e

4 files changed

Lines changed: 95 additions & 4 deletions

File tree

PyPDF2/_reader.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -730,7 +730,7 @@ def outlines(self):
730730
"""Read-only property."""
731731
return self._get_outlines()
732732

733-
def _get_outlines(self, node=None, outlines=None):
733+
def _get_outlines(self, node=None, outlines=None, visited=None):
734734
"""
735735
Retrieve the document outline present in the document.
736736
@@ -758,16 +758,27 @@ def _get_outlines(self, node=None, outlines=None):
758758
if node is None:
759759
return outlines
760760

761+
if visited is None:
762+
visited = set()
763+
761764
# see if there are any more outlines
762765
while True:
766+
# CVE-2026-24688: a crafted outline whose /Next chain loops back to
767+
# an already-seen node made this walk run forever. Stop on a repeat.
768+
node_id = id(node)
769+
if node_id in visited:
770+
warnings.warn("Cycle detected in document outline; stopping")
771+
break
772+
visited.add(node_id)
773+
763774
outline = self._build_outline(node)
764775
if outline:
765776
outlines.append(outline)
766777

767778
# check for sub-outlines
768779
if "/First" in node:
769780
sub_outlines = []
770-
self._get_outlines(node["/First"], sub_outlines)
781+
self._get_outlines(node["/First"], sub_outlines, visited.copy())
771782
if sub_outlines:
772783
outlines.append(sub_outlines)
773784

PyPDF2/xmp.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,32 @@
22
import decimal
33
import re
44
import warnings
5-
from xml.dom.minidom import parseString
5+
from xml.dom.expatbuilder import ExpatBuilderNS
6+
from xml.parsers.expat import ExpatError
67

78
from ._utils import DEPR_MSG, u_
89
from .generic import PdfObject
910

11+
12+
class _SafeExpatBuilder(ExpatBuilderNS):
13+
"""Namespace-aware XML builder that refuses all entity declarations.
14+
15+
CVE-2026-40260: parsing untrusted XMP with the default parser allowed XML
16+
entity expansion (quadratic blow-up, which libexpat does not block) to
17+
exhaust memory. Rejecting any entity declaration outright closes that.
18+
"""
19+
20+
def _deny_entity(self, *args):
21+
raise ExpatError("Entity declarations are not permitted in XMP")
22+
23+
def install(self, parser):
24+
ExpatBuilderNS.install(self, parser)
25+
parser.EntityDeclHandler = self._deny_entity
26+
27+
28+
def _safe_parse_xmp(data):
29+
return _SafeExpatBuilder().parseString(data)
30+
1031
RDF_NAMESPACE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
1132
DC_NAMESPACE = "http://purl.org/dc/elements/1.1/"
1233
XMP_NAMESPACE = "http://ns.adobe.com/xap/1.0/"
@@ -88,7 +109,7 @@ class XmpInformation(PdfObject):
88109

89110
def __init__(self, stream):
90111
self.stream = stream
91-
doc_root = parseString(self.stream.get_data())
112+
doc_root = _safe_parse_xmp(self.stream.get_data())
92113
self.rdfRoot = doc_root.getElementsByTagNameNS(RDF_NAMESPACE, "RDF")[0]
93114
self.cache = {}
94115

Tests/test_security_outline.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# -*- coding: utf-8 -*-
2+
"""Regression test for CVE-2026-24688: cyclic document outline /Next loop."""
3+
import warnings
4+
5+
from PyPDF2._reader import PdfReader
6+
from PyPDF2.generic import DictionaryObject, NameObject
7+
8+
9+
def test_get_outlines_cycle_terminates():
10+
reader = PdfReader.__new__(PdfReader)
11+
reader._build_outline = lambda node: None # isolate the walk
12+
a = DictionaryObject()
13+
b = DictionaryObject()
14+
a[NameObject("/Next")] = b
15+
b[NameObject("/Next")] = a # cycle that never ends
16+
with warnings.catch_warnings():
17+
warnings.simplefilter("ignore")
18+
result = reader._get_outlines(a, []) # must terminate, not hang
19+
assert result == []

Tests/test_security_xmp.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# -*- coding: utf-8 -*-
2+
"""Regression test for CVE-2026-40260: XMP XML entity expansion."""
3+
import pytest
4+
5+
from xml.parsers.expat import ExpatError
6+
7+
from PyPDF2.xmp import XmpInformation
8+
9+
10+
class _FakeStream(object):
11+
def __init__(self, data):
12+
self._data = data
13+
14+
def get_data(self):
15+
return self._data
16+
17+
18+
_RDF = (
19+
b'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
20+
b"<rdf:Description/></rdf:RDF>"
21+
)
22+
23+
24+
def test_xmp_normal_parses():
25+
xml = (
26+
b'<?xml version="1.0"?>'
27+
b'<x:xmpmeta xmlns:x="adobe:ns:meta/">' + _RDF + b"</x:xmpmeta>"
28+
)
29+
xmp = XmpInformation(_FakeStream(xml))
30+
assert xmp.rdfRoot is not None
31+
32+
33+
def test_xmp_entity_declaration_rejected():
34+
xml = (
35+
b'<?xml version="1.0"?>'
36+
b'<!DOCTYPE x [ <!ENTITY boom "AAAA"> ]>'
37+
b'<x:xmpmeta xmlns:x="adobe:ns:meta/">' + _RDF + b"</x:xmpmeta>"
38+
)
39+
with pytest.raises(ExpatError):
40+
XmpInformation(_FakeStream(xml))

0 commit comments

Comments
 (0)