@@ -14,11 +14,122 @@ tables). Prefer this when the goal is content, not coordinates.
1414## Structured access (python-docx)
1515
1616``` python
17+ from contextlib import contextmanager
18+ import zipfile
19+ from pathlib import Path
20+ from tempfile import TemporaryFile
21+
1722from docx import Document
1823from docx.oxml.ns import qn
1924from docx.table import Table
2025from docx.text.paragraph import Paragraph
2126from docx.text.run import Run
27+ from lxml import etree
28+
29+ MAX_ARCHIVE_BYTES = 200 * 1024 * 1024
30+ MAX_MEMBERS = 10_000
31+ MAX_XML_PART = 20 * 1024 * 1024
32+ MAX_ENTRY = 100 * 1024 * 1024
33+ MAX_TOTAL_UNCOMPRESSED = 500 * 1024 * 1024
34+ MAX_COMPRESSION_RATIO = 200
35+ CONTENT_TYPES_NAMESPACE = " http://schemas.openxmlformats.org/package/2006/content-types"
36+
37+ def require (condition , message ):
38+ if not condition:
39+ raise ValueError (message)
40+
41+ SAFE_XML_PARSER = etree.XMLParser(
42+ load_dtd = False , resolve_entities = False , no_network = True ,
43+ huge_tree = False , recover = False ,
44+ )
45+
46+ def xml_content_type (value ):
47+ media_type = (value or " " ).split(" ;" , 1 )[0 ].strip().casefold()
48+ return media_type in {" application/xml" , " text/xml" } or media_type.endswith(" +xml" )
49+
50+ def declared_xml_parts (archive , infos ):
51+ """ Classify XML by OPC declarations, not filename spelling alone."""
52+ by_name = {info.filename: info for info in infos}
53+ content_types_info = by_name.get(" [Content_Types].xml" )
54+ require(content_types_info is not None , " missing [Content_Types].xml" )
55+ require(content_types_info.file_size <= MAX_XML_PART ,
56+ " oversized XML part: [Content_Types].xml" )
57+ with archive.open(content_types_info) as stream:
58+ content_types_blob = stream.read(MAX_XML_PART + 1 )
59+ require(len (content_types_blob) <= MAX_XML_PART ,
60+ " oversized XML part: [Content_Types].xml" )
61+ root = etree.fromstring(content_types_blob, parser = SAFE_XML_PARSER )
62+ require(root.tag == f " {{ { CONTENT_TYPES_NAMESPACE } }} Types " ,
63+ " invalid [Content_Types].xml root" )
64+ defaults = {}
65+ overrides = {}
66+ for child in root:
67+ if child.tag == f " {{ { CONTENT_TYPES_NAMESPACE } }} Default " :
68+ extension = (child.get(" Extension" ) or " " ).casefold()
69+ require(extension and extension not in defaults,
70+ " invalid duplicate content-type default" )
71+ defaults[extension] = child.get(" ContentType" ) or " "
72+ elif child.tag == f " {{ { CONTENT_TYPES_NAMESPACE } }} Override " :
73+ part_name = child.get(" PartName" ) or " "
74+ require(part_name.startswith(" /" ) and part_name[1 :] not in overrides,
75+ " invalid duplicate content-type override" )
76+ overrides[part_name[1 :]] = child.get(" ContentType" ) or " "
77+ xml_names = {" [Content_Types].xml" }
78+ for info in infos:
79+ suffix = info.filename.rsplit(" ." , 1 )[1 ].casefold() if " ." in info.filename else " "
80+ content_type = overrides.get(info.filename, defaults.get(suffix, " " ))
81+ if (info.filename.casefold().endswith((" .xml" , " .rels" ))
82+ or xml_content_type(content_type)):
83+ xml_names.add(info.filename)
84+ return xml_names
85+
86+ def validate_docx_archive (archive ):
87+ infos = archive.infolist()
88+ require(len (infos) <= MAX_MEMBERS , " archive member count above limit" )
89+ names = {info.filename for info in infos}
90+ require(len (names) == len (infos), " duplicate archive member names are unsafe" )
91+ require(" [Content_Types].xml" in names and " word/document.xml" in names,
92+ " missing required OPC members" )
93+ require(sum (info.file_size for info in infos) <= MAX_TOTAL_UNCOMPRESSED ,
94+ " declared total uncompressed size above limit" )
95+ xml_names = declared_xml_parts(archive, infos)
96+ actual_total = 0
97+ for info in infos:
98+ require(info.file_size <= MAX_ENTRY , f " oversized part: { info.filename} " )
99+ require(info.file_size / max (info.compress_size, 1 ) <= MAX_COMPRESSION_RATIO ,
100+ f " suspicious compression ratio: { info.filename} " )
101+ is_xml = info.filename in xml_names
102+ if is_xml:
103+ require(info.file_size <= MAX_XML_PART , f " oversized XML part: { info.filename} " )
104+ chunks = []
105+ actual_size = 0
106+ with archive.open(info) as stream:
107+ while chunk := stream.read(64 * 1024 ):
108+ actual_size += len (chunk)
109+ actual_total += len (chunk)
110+ require(actual_size <= MAX_ENTRY , f " part exceeded read limit: { info.filename} " )
111+ require(actual_total <= MAX_TOTAL_UNCOMPRESSED ,
112+ " archive exceeded total read limit" )
113+ if is_xml:
114+ chunks.append(chunk)
115+ require(actual_size == info.file_size, f " size mismatch: { info.filename} " )
116+ if is_xml:
117+ etree.fromstring(b " " .join(chunks), parser = SAFE_XML_PARSER )
118+
119+ @contextmanager
120+ def validated_docx_source (path ):
121+ """ Yield one private, bounded snapshot for both validation and python-docx."""
122+ with Path(path).open(" rb" ) as external_source, TemporaryFile() as source:
123+ copied = 0
124+ while chunk := external_source.read(64 * 1024 ):
125+ copied += len (chunk)
126+ require(copied <= MAX_ARCHIVE_BYTES , " compressed DOCX file size above limit" )
127+ source.write(chunk)
128+ source.seek(0 )
129+ with zipfile.ZipFile(source) as archive:
130+ validate_docx_archive(archive)
131+ source.seek(0 )
132+ yield source
22133
23134MC_NAMESPACE = " http://schemas.openxmlformats.org/markup-compatibility/2006"
24135MC_ALTERNATE_CONTENT = f " {{ { MC_NAMESPACE } }} AlternateContent "
@@ -227,17 +338,18 @@ def table_content(table):
227338 })
228339 return rows
229340
230- doc = Document(" input.docx" )
231- content_controls = list (doc.element.body.iter(qn(" w:sdt" )))
232- blocks = list (iter_part_blocks(doc.element.body, doc))
233- print (" content controls:" , len (content_controls), " top-level blocks:" , len (blocks))
234- for kind, block in blocks:
235- if kind == " paragraph" :
236- print (block.style.name, " |" , paragraph_text(block))
237- elif kind == " table" :
238- print (" table |" , table_content(block))
239- else :
240- print (" unreadable |" , block)
341+ with validated_docx_source(" input.docx" ) as source:
342+ doc = Document(source)
343+ content_controls = list (doc.element.body.iter(qn(" w:sdt" )))
344+ blocks = list (iter_part_blocks(doc.element.body, doc))
345+ print (" content controls:" , len (content_controls), " top-level blocks:" , len (blocks))
346+ for kind, block in blocks:
347+ if kind == " paragraph" :
348+ print (block.style.name, " |" , paragraph_text(block))
349+ elif kind == " table" :
350+ print (" table |" , table_content(block))
351+ else :
352+ print (" unreadable |" , block)
241353```
242354
243355Notes:
0 commit comments