114 lines
4.1 KiB
Python
Executable File
114 lines
4.1 KiB
Python
Executable File
"""
|
|
Safe XML parsing — defused against XXE, billion laughs, and entity expansion.
|
|
|
|
Centralizes all XML parsing so every entry point (parser, writer, export,
|
|
rough_cut) uses the same hardened functions. Drop-in replacements for
|
|
ET.parse() and ET.fromstring().
|
|
|
|
Blocks:
|
|
- External entity injection (XXE): file:///etc/passwd, http:// callbacks
|
|
- Billion laughs / entity expansion: exponential DTD bombs
|
|
- DTD retrieval: remote DTD loading
|
|
|
|
Security note: All flags are set explicitly rather than relying on library
|
|
defaults — this ensures protection survives dependency upgrades that might
|
|
change default behavior.
|
|
|
|
IMPORTANT: Never use stdlib xml.etree.ElementTree.parse() or .fromstring()
|
|
directly in this codebase. Always import from this module instead.
|
|
"""
|
|
|
|
import xml.etree.ElementTree as ET
|
|
from xml.dom.minidom import Document
|
|
|
|
import defusedxml.ElementTree as _safe_ET
|
|
import defusedxml.minidom as _safe_minidom
|
|
|
|
# Explicit security flags — pinned so a defusedxml upgrade that changes
|
|
# defaults cannot silently weaken the boundary.
|
|
#
|
|
# forbid_dtd is False because FCPXML files legitimately include
|
|
# <!DOCTYPE fcpxml> — blocking all DTDs would reject every real FCP export.
|
|
# forbid_entities and forbid_external block the dangerous payloads
|
|
# (entity expansion bombs, external entity reads, remote DTD fetches).
|
|
_SECURITY_FLAGS = {
|
|
"forbid_dtd": False,
|
|
"forbid_entities": True,
|
|
"forbid_external": True,
|
|
}
|
|
|
|
|
|
def safe_parse(source: str) -> ET.ElementTree:
|
|
"""Parse an XML file with XXE and entity-expansion protection.
|
|
|
|
All DTD processing, entity definitions, and external references are
|
|
rejected outright. Returns a standard ElementTree so downstream code
|
|
is unchanged.
|
|
"""
|
|
return _safe_ET.parse(source, **_SECURITY_FLAGS)
|
|
|
|
|
|
def safe_fromstring(text: str) -> ET.Element:
|
|
"""Parse an XML string with XXE and entity-expansion protection.
|
|
|
|
All DTD processing, entity definitions, and external references are
|
|
rejected outright. Returns a standard Element so downstream code is
|
|
unchanged.
|
|
"""
|
|
return _safe_ET.fromstring(text, **_SECURITY_FLAGS)
|
|
|
|
|
|
def safe_parse_string(text: str) -> Document:
|
|
"""Parse an XML string into a minidom Document with XXE protection.
|
|
|
|
Drop-in replacement for xml.dom.minidom.parseString(). Used in the
|
|
pretty-print path (writer.py, export.py) to maintain defense-in-depth
|
|
even when re-parsing XML that was already produced by safe_parse().
|
|
|
|
Why this matters: if a future refactor passes unsanitized XML through
|
|
serialize_xml(), stdlib minidom would silently process external
|
|
entities and DTD bombs. Using defusedxml.minidom closes that gap.
|
|
"""
|
|
return _safe_minidom.parseString(text)
|
|
|
|
|
|
def serialize_xml(root: ET.Element, filepath: str, doctype: str = "") -> str:
|
|
"""Pretty-print an ElementTree root and write to disk.
|
|
|
|
Single serialization pipeline shared by all XML output paths
|
|
(write_fcpxml for FCPXML, DaVinciExporter for XMEML / simplified exports).
|
|
Consolidates the ET.tostring → minidom → toprettyxml → strip blanks →
|
|
replace declaration → write-to-file sequence that was previously
|
|
duplicated across writer.py and export.py.
|
|
|
|
Args:
|
|
root: The XML root Element to serialize.
|
|
filepath: Destination file path.
|
|
doctype: DOCTYPE declaration to insert after the XML declaration.
|
|
Example: ``'<!DOCTYPE fcpxml>'`` or ``'<!DOCTYPE xmeml>'``.
|
|
If empty, no DOCTYPE is inserted.
|
|
|
|
Returns:
|
|
The filepath written to.
|
|
"""
|
|
xml_str = ET.tostring(root, encoding='unicode')
|
|
dom = safe_parse_string(xml_str)
|
|
pretty_xml = dom.toprettyxml(indent=" ")
|
|
lines = [line for line in pretty_xml.split('\n') if line.strip()]
|
|
final_xml = '\n'.join(lines)
|
|
|
|
if doctype:
|
|
final_xml = final_xml.replace(
|
|
'<?xml version="1.0" ?>',
|
|
f'<?xml version="1.0" encoding="UTF-8"?>\n{doctype}',
|
|
)
|
|
else:
|
|
final_xml = final_xml.replace(
|
|
'<?xml version="1.0" ?>',
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
)
|
|
|
|
with open(filepath, 'w', encoding='utf-8') as f:
|
|
f.write(final_xml)
|
|
return filepath
|