harden XML parsing against billion-laughs DoS in extract_csproj and extract_lpk

stdlib ET does not cap entity expansion — a crafted .csproj or .lpk with nested
internal entities can exhaust memory. Pre-screen input bytes for <!DOCTYPE and
<!ENTITY before parsing (legitimate MSBuild/Lazarus files never contain these).
Also adds the missing 2 MiB size cap to extract_lpk (csproj already had one).
No new dependencies required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-26 20:32:14 +01:00
parent e5313e73b2
commit ad3f3b2d9e
+40 -3
View File
@@ -7389,6 +7389,30 @@ def extract_delphi_form(path: Path) -> dict:
return {"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0}
# Size cap for project XML files we parse with stdlib ElementTree.
# Real .csproj/.fsproj/.vbproj/.lpk files are well under 2 MiB; anything
# larger is either malformed or hostile.
_PROJECT_XML_MAX_BYTES = 2 * 1024 * 1024
def _project_xml_is_safe(src: bytes) -> bool:
"""Reject XML that declares DTDs or entities.
Stdlib ``xml.etree.ElementTree`` does not cap entity expansion, so a
crafted project file could trigger a billion-laughs style DoS. External
entity resolution is already disabled by pyexpat defaults, but rejecting
``<!DOCTYPE`` / ``<!ENTITY`` outright is defense in depth.
Legitimate MSBuild and Lazarus package files never contain a DOCTYPE
or ENTITY declaration, so this is a zero-false-positive screen.
"""
# Only the prolog can hold a DTD/internal subset, but be conservative
# and scan the full byte range -- these formats use ASCII tags so a
# case-insensitive substring match is sufficient.
lowered = src.lower()
return b"<!doctype" not in lowered and b"<!entity" not in lowered
def extract_lazarus_package(path: Path) -> dict:
"""Extract package metadata from Lazarus .lpk package files (XML format).
@@ -7408,8 +7432,18 @@ def extract_lazarus_package(path: Path) -> dict:
"""
try:
import xml.etree.ElementTree as ET
text = path.read_text(encoding="utf-8", errors="replace")
xml_root = ET.fromstring(text)
src = path.read_bytes()
except OSError as e:
return {"nodes": [], "edges": [], "error": str(e)}
if len(src) > _PROJECT_XML_MAX_BYTES:
return {"nodes": [], "edges": [], "error": "package file too large"}
if not _project_xml_is_safe(src):
return {"nodes": [], "edges": [],
"error": "refusing XML with DOCTYPE/ENTITY declaration"}
try:
xml_root = ET.fromstring(src)
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
@@ -7798,8 +7832,11 @@ def extract_csproj(path: Path) -> dict:
except OSError:
return {"nodes": [], "edges": [], "error": f"cannot read {path}"}
if len(src) > 2_097_152:
if len(src) > _PROJECT_XML_MAX_BYTES:
return {"nodes": [], "edges": [], "error": "project file too large"}
if not _project_xml_is_safe(src):
return {"nodes": [], "edges": [],
"error": "refusing XML with DOCTYPE/ENTITY declaration"}
try:
tree = ET.fromstring(src)