From 6fe1efd0831b7f1c676cef9dd061ce9cb5e31a3e Mon Sep 17 00:00:00 2001 From: Panos Vagenas Date: Mon, 13 Jul 2026 13:12:23 +0200 Subject: [PATCH] reject DTD and entity references in DocLang validation - Parse user documents with entities disabled and reject DTD declarations or entity nodes; use the same path for XSD and Schematron input - Omit DOCTYPE when serializing temp XML for Saxon, and cover this with validation tests Signed-off-by: Panos Vagenas --- doclang/backends/saxonche.py | 7 ++-- doclang/utils.py | 61 ++++++++++++++++++++++++++++++ doclang/xsd_validation.py | 7 ++-- tests/test_validation.py | 73 ++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 6 deletions(-) diff --git a/doclang/backends/saxonche.py b/doclang/backends/saxonche.py index f0bff0a..c8f0887 100644 --- a/doclang/backends/saxonche.py +++ b/doclang/backends/saxonche.py @@ -13,7 +13,7 @@ from doclang.backends._svrl import _svrl_failed_asserts_to_violations from doclang.schematron import SchematronViolation, _require_saxonche_backend -from doclang.utils import _ensure_namespace +from doclang.utils import _ensure_namespace, _parse_doclang_document, _write_xml_without_dtd # ISO Schematron transpiler - converts .sch to XSLT 3.0 _ISO_SCHEMATRON_TRANSPILER = """ @@ -139,13 +139,14 @@ def validate( print(f"Using Schematron file: {schema_path}") with open(xml_path, "rb") as f: - xml_doc = etree.parse(f) + xml_doc = _parse_doclang_document(f) if allow_empty_namespace: xml_doc = _ensure_namespace(xml_doc) with tempfile.NamedTemporaryFile(mode="wb", suffix=".xml", delete=True) as tmp: - xml_doc.write(tmp, encoding="utf-8", xml_declaration=True) + # Never re-emit a DOCTYPE: Saxon would otherwise expand entities. + _write_xml_without_dtd(xml_doc, tmp) tmp.flush() tmp_xml_path = tmp.name diff --git a/doclang/utils.py b/doclang/utils.py index a80403b..77a4532 100644 --- a/doclang/utils.py +++ b/doclang/utils.py @@ -2,6 +2,9 @@ Shared utility functions for DocLang validation. """ +from pathlib import Path +from typing import BinaryIO, Protocol, Union + from lxml import etree from doclang.version import _resolve_version @@ -9,6 +12,64 @@ _DOCLANG_NAMESPACE = "https://www.doclang.ai/ns/v0" _VERSION = _resolve_version() +_DTD_REJECTED_MESSAGE = "DTD declarations and entity references are not allowed in DocLang documents" + + +class _BinaryWriter(Protocol): + def write(self, data: bytes, /) -> int: ... + + +def _safe_xml_parser() -> etree.XMLParser: + """Return an lxml parser that does not expand entities or load external DTDs.""" + return etree.XMLParser( + resolve_entities=False, + no_network=True, + load_dtd=False, + dtd_validation=False, + ) + + +def _contains_entity_nodes(node: etree._Element) -> bool: + for child in node: + if not isinstance(child.tag, str): + # Comments/PIs are skipped elsewhere; treat non-element nodes as unsafe + # when they are entity references (lxml exposes them as _Entity). + if type(child).__name__ == "_Entity": + return True + continue + if _contains_entity_nodes(child): + return True + return False + + +def _reject_dtd_or_entities(xml_doc: etree._ElementTree) -> None: + """Fail closed if the document declares a DTD or contains entity nodes.""" + if (xml_doc.docinfo.doctype or "").strip() or _contains_entity_nodes(xml_doc.getroot()): + raise ValueError(_DTD_REJECTED_MESSAGE) + + +def _parse_xml(source: Union[str, Path, BinaryIO]) -> etree._ElementTree: + """Parse XML with :func:`_safe_xml_parser` (no DTD/entity policy checks).""" + return etree.parse(source, parser=_safe_xml_parser()) + + +def _parse_doclang_document(source: Union[str, Path, BinaryIO]) -> etree._ElementTree: + """Parse a DocLang document safely and reject DTD / entity payloads.""" + xml_doc = _parse_xml(source) + _reject_dtd_or_entities(xml_doc) + return xml_doc + + +def _write_xml_without_dtd(xml_doc: etree._ElementTree, out: _BinaryWriter) -> None: + """Serialize ``xml_doc`` without preserving any DOCTYPE / internal subset.""" + out.write( + etree.tostring( + xml_doc.getroot(), + encoding="utf-8", + xml_declaration=True, + ) + ) + def _ensure_namespace(xml_doc: etree._ElementTree) -> etree._ElementTree: """ diff --git a/doclang/xsd_validation.py b/doclang/xsd_validation.py index aa3cf66..275873e 100644 --- a/doclang/xsd_validation.py +++ b/doclang/xsd_validation.py @@ -10,7 +10,7 @@ from lxml import etree from doclang._schemas import _bundled_xsd_path -from doclang.utils import _ensure_namespace +from doclang.utils import _ensure_namespace, _parse_doclang_document, _parse_xml def _validate_xsd_at( @@ -29,11 +29,12 @@ def _validate_xsd_at( """ try: with open(xsd_file, "rb") as f: - schema_doc = etree.parse(f) + # Bundled/trusted schema: hardened parse only (no DocLang DTD policy). + schema_doc = _parse_xml(f) schema = etree.XMLSchema(schema_doc) with open(xml_file, "rb") as f: - xml_doc = etree.parse(f) + xml_doc = _parse_doclang_document(f) if allow_empty_namespace: xml_doc = _ensure_namespace(xml_doc) diff --git a/tests/test_validation.py b/tests/test_validation.py index f64e2b1..b9bb03d 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -14,6 +14,7 @@ import doclang from doclang import SchematronBackendNotFound, SchematronViolation, ValidationError, validate +from doclang.utils import _DTD_REJECTED_MESSAGE, _write_xml_without_dtd TEST_DATA_DIR = Path(__file__).parent / "data" VALID_DIR = TEST_DATA_DIR / "valid" @@ -62,6 +63,78 @@ def test_invalid_reports_both_xsd_and_schematron_errors(): assert len(exc.schematron_errors) == 1 +def test_dtd_and_entity_payloads_are_rejected(tmp_path: Path): + """DTD / entity payloads are rejected with a clear error (no expansion).""" + bomb = """ + + + + + +]> + + &e; + +""" + xml_file = tmp_path / "entity_bomb.dclg" + xml_file.write_text(bomb, encoding="utf-8") + + with pytest.raises(ValidationError) as exc_info: + validate(xml_file, xsd_only=True) + + assert any(_DTD_REJECTED_MESSAGE in (err.get("error") or "") for err in exc_info.value.xsd_errors) + + +def test_doctype_without_entities_is_rejected(tmp_path: Path): + """A DOCTYPE alone is enough to reject — DocLang does not use DTDs.""" + xml_file = tmp_path / "with_doctype.dclg" + xml_file.write_text( + """ + + + Hello + +""", + encoding="utf-8", + ) + + with pytest.raises(ValidationError) as exc_info: + validate(xml_file, xsd_only=True) + + assert any(_DTD_REJECTED_MESSAGE in (err.get("error") or "") for err in exc_info.value.xsd_errors) + + +def test_schematron_temp_serialization_omits_doctype(tmp_path: Path): + """Saxon input must not re-emit a DOCTYPE even if parse somehow retained one.""" + # Build a tree via the safe parser without going through the reject helper, + # then ensure the Schematron rewrite path strips DOCTYPE. + from doclang.utils import _parse_xml + + xml_file = tmp_path / "doctype.dclg" + xml_file.write_text( + """ + +]> + + &x; + +""", + encoding="utf-8", + ) + doc = _parse_xml(xml_file) + assert (doc.docinfo.doctype or "").strip() + + out = tmp_path / "rewritten.xml" + with out.open("wb") as handle: + _write_xml_without_dtd(doc, handle) + + rewritten = out.read_text(encoding="utf-8") + assert "