"""Sprint S1.4 — Tests d'attaque XXE / Billion Laughs / DTD retrieval. Vérifie que ``picarones.formats._xml_utils.safe_parse_xml`` **rejette** les payloads malicieux que l'audit prétendait défendre via ``defusedxml``. Sans ces tests, la défense est invisible : un refactor pourrait bypasser ``defusedxml`` sans qu'aucun test n'échoue. Vecteurs couverts ----------------- 1. **XXE** (XML External Entity) — résolution d'entité vers un fichier local ``/etc/passwd`` ou une URL distante. 2. **Billion Laughs** — expansion exponentielle d'entités (``lol1`` → ``lol2`` × 10 → ``lol3`` × 100 → ...). 3. **DTD retrieval** — fetch d'une DTD distante (SSRF côté parser). 4. **Quadratic blowup** — grosse entité répétée linéairement. """ from __future__ import annotations from picarones.formats._xml_utils import safe_parse_xml # ────────────────────────────────────────────────────────────────────── # 1. XXE — fichier local # ────────────────────────────────────────────────────────────────────── class TestXXEFileExfiltration: """Une entité externe pointant sur ``/etc/passwd`` doit être refusée — sinon le parser retourne le contenu du fichier dans le résultat XML.""" def test_xxe_file_uri_is_blocked(self) -> None: payload = ( b'' b'' b']>' b'&xxe;' ) result = safe_parse_xml(payload) # safe_parse_xml retourne None en cas de détection d'attaque # (defusedxml.EntitiesForbidden / DTDForbidden). assert result is None, ( "XXE non bloqué : safe_parse_xml a accepté un payload " "avec ```` ; un " "attaquant pourrait exfiltrer ``/etc/passwd`` ou tout " "autre fichier lisible par le process." ) def test_xxe_http_uri_is_blocked(self) -> None: """Variante : entité externe vers une URL HTTP (SSRF côté parser, peut exfiltrer la requête vers un serveur de l'attaquant).""" payload = ( b'' b'' b']>' b'&xxe;' ) result = safe_parse_xml(payload) assert result is None # ────────────────────────────────────────────────────────────────────── # 2. Billion Laughs — DoS par expansion d'entités # ────────────────────────────────────────────────────────────────────── class TestBillionLaughs: """L'attaque historique XML : 10 entités imbriquées → 10^10 expansion = OOM kill.""" def test_billion_laughs_is_blocked(self) -> None: payload = ( b'' b'' b' ' b' ' b' ' b' ' b']>' b'&lol5;' ) result = safe_parse_xml(payload) assert result is None, ( "Billion Laughs non bloqué : le parser a accepté une " "expansion exponentielle d'entités (DoS / OOM)." ) # ────────────────────────────────────────────────────────────────────── # 3. DTD retrieval — DoCTYPE externe # ────────────────────────────────────────────────────────────────────── class TestDTDRetrieval: """Une DTD externe est un fetch HTTP/HTTPS depuis le parser ; c'est une SSRF + fuite d'info.""" def test_external_dtd_is_blocked(self) -> None: payload = ( b'' b'' b'data' ) result = safe_parse_xml(payload) assert result is None, ( "DTD retrieval non bloqué : ```` peut déclencher une requête HTTP " "depuis le serveur Picarones (SSRF)." ) # ────────────────────────────────────────────────────────────────────── # 4. Sanity — XML légitime doit passer # ────────────────────────────────────────────────────────────────────── class TestLegitimateXMLPasses: """Garde-fou : les durcissements ne doivent pas casser un document ALTO ou PAGE XML sans entités.""" def test_simple_alto_xml_parses(self) -> None: payload = ( b'' b'' b' ' b' ' b' ' b'' ) result = safe_parse_xml(payload) assert result is not None, ( "ALTO XML légitime refusé — fausse alerte." ) assert result.tag.endswith("alto") def test_xml_with_entities_internes_parses(self) -> None: """Les entités HTML standards (&, <, >, ", ') doivent rester acceptées (resolved par le parser sans aller chercher de DTD).""" payload = ( b'' b'R&D <tag>' ) result = safe_parse_xml(payload) assert result is not None assert result.text == "R&D " # ────────────────────────────────────────────────────────────────────── # 5. XML invalide retourne None (pas d'exception qui remonte) # ────────────────────────────────────────────────────────────────────── class TestInvalidXMLReturnsNone: def test_truncated_xml_returns_none(self) -> None: result = safe_parse_xml(b'') assert result is None def test_empty_bytes_returns_none(self) -> None: result = safe_parse_xml(b'') assert result is None def test_non_xml_bytes_returns_none(self) -> None: result = safe_parse_xml(b'not xml at all just text') assert result is None