From ae52cadde74544700e94647946c84ae366950cd7 Mon Sep 17 00:00:00 2001 From: Rossi-Luciano Date: Sun, 16 Aug 2026 20:18:09 -0300 Subject: [PATCH] fix(security): bump lxml para 6.1.1, corrige XXE de leitura de arquivo local (fase 4, #1251) Fase 4 da estrategia de atualizacao de dependencias, com prioridade elevada por envolver uma vulnerabilidade ativa, nao apenas uma CVE rotineira. ## Vulnerabilidade corrigida (PYSEC-2026-87) lxml < 6.1.0 resolve entidades externas por padrao (resolve_entities=True), permitindo que um XML nao confiavel leia arquivos locais via . Confirmado empiricamente neste repositorio: um XML malicioso processado por packtools.XML() (o parser principal, load_dtd=True, no_network=True) conseguia vazar o conteudo de um arquivo local arbitrario. no_network so bloqueia esquemas de rede (http/ftp), nao file://. Como packtools processa pacotes XML de terceiros por definicao, isso e um vetor de exfiltracao de arquivo local ativo, nao teorico. ## Correcoes aplicadas 1. lxml 4.9.3 -> 6.1.1 em requirements.txt (muda o default para resolve_entities='internal', a partir da 6.1.0). 2. Endurecimento explicito: resolve_entities="internal" adicionado nos 5 pontos onde XMLParser() e instanciado (packtools/utils.py, packtools/sps/pid_provider/xml_loader.py, packtools/sps/utils/xml_utils.py), para nao depender apenas do default da biblioteca. 'internal' (nao False) foi escolhido porque preserva a resolucao de entidades internas (ex.: entidades de caracteres definidas inline no DOCTYPE), testado e confirmado nao quebrar nenhum caso legitimo; resolve_entities=False bloquearia isso tambem. 3. setup.py: piso de 'lxml>=4.9.2' para 'lxml>=6.1.0', excecao pontual a regra geral de deixar setup.py para a fase 8, justificada pela gravidade: manter o piso solto deixaria `pip install packtools` (sem passar por requirements.txt) potencialmente vulneravel mesmo apos este fix. 4. tox.ini: fator lxml492 -> lxml611, para nao testar mais deliberadamente contra uma versao do lxml com CVE conhecida aberta. ## Regressao real encontrada e corrigida (nao relacionada a seguranca) Rodar a suite completa com lxml 6.1.1 sozinho (sem o endurecimento acima) revelou 15 falhas novas em packtools/sps/utils/xml_utils.py e packtools/sps/models/test_article_titles.py, alem do flake ja conhecido de test_i18n.py. Causa raiz isolada com reproducao minima comparando lxml 4.9.3 vs 6.1.1: Element.addnext() deixou de mover automaticamente o .tail do elemento de origem para o elemento inserido. O workaround existente em process_xref() (marcador EMPTYTAGTOKEEPXREFTAIL, usado para preservar o texto apos um antes de parent.remove(xref) descarta-lo) dependia desse efeito colateral implicito do lxml 4.x para funcionar. Corrigido movendo o tail explicitamente (e.tail = xref.tail; xref.tail = None) antes do addnext(), o que funciona identicamente em ambas as versoes (validado nas duas). ## Validacao pytest completo (venv isolado, so lxml + os 3 arquivos de codigo alterados, resto identico ao master): apos o fix do process_xref, duas rodadas deram 40 failed / 5973 passed / 30 skipped, identico ao baseline do master (a primeira rodada, antes do fix, mostrou 55 failed, confirmando a regressao real acima). tox -e py310-lxml611: 23 failures + 19 errors, numeros identicos aos ja documentados na fase 1 (PR #1280) e causados pelo mesmo gap pre-existente (Pillow sem pin em setup.py resolvendo para uma versao mais nova via bounds soltos) -- nao relacionado a este bump de lxml. --- packtools/sps/pid_provider/xml_loader.py | 2 +- packtools/sps/utils/xml_utils.py | 8 +++++++- packtools/utils.py | 7 ++++--- requirements.txt | 2 +- setup.py | 2 +- tox.ini | 4 ++-- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packtools/sps/pid_provider/xml_loader.py b/packtools/sps/pid_provider/xml_loader.py index a4036a544..c04a1d5d3 100644 --- a/packtools/sps/pid_provider/xml_loader.py +++ b/packtools/sps/pid_provider/xml_loader.py @@ -83,7 +83,7 @@ def xml_parser_ent2char(xml): ao invés de convertidas para seus caracteres correspondentes. """ try: - parser = etree.XMLParser(recover=True, encoding="utf-8") + parser = etree.XMLParser(recover=True, encoding="utf-8", resolve_entities="internal") root = etree.fromstring(xml, parser) return etree.tostring(root, method="xml", encoding="utf-8").decode("utf-8") except Exception as e: diff --git a/packtools/sps/utils/xml_utils.py b/packtools/sps/utils/xml_utils.py index 24aebf298..887c9a0b3 100644 --- a/packtools/sps/utils/xml_utils.py +++ b/packtools/sps/utils/xml_utils.py @@ -43,6 +43,12 @@ def process_xref(node, footnote_markers=None): _next = xref.getnext() if _next is None or _next.tag != "xref": e = etree.Element("EMPTYTAGTOKEEPXREFTAIL") + # Move o tail explicitamente antes do addnext: em lxml >= 5, + # addnext() deixou de transferir o tail automaticamente para + # o elemento inserido, o que fazia parent.remove(xref) (abaixo) + # descartar esse texto junto com o xref removido. + e.tail = xref.tail + xref.tail = None xref.addnext(e) for xref in node.findall(".//xref"): @@ -138,7 +144,7 @@ def _get_xml_content(xml): def get_xml_tree(content): - parser = etree.XMLParser(remove_blank_text=True, no_network=True) + parser = etree.XMLParser(remove_blank_text=True, no_network=True, resolve_entities="internal") try: content = _get_xml_content(content) xml_tree = etree.XML(content, parser) diff --git a/packtools/utils.py b/packtools/utils.py index 56cbfb16b..77901d7da 100644 --- a/packtools/utils.py +++ b/packtools/utils.py @@ -31,11 +31,11 @@ try: # available on lxml >= 3.4.0 - NOIDS_XMLPARSER = etree.XMLParser(collect_ids=False) + NOIDS_XMLPARSER = etree.XMLParser(collect_ids=False, resolve_entities="internal") except TypeError: LOGGER.info('cannot instantiate an XML parser that avoids the collection ' 'of ids from elements.') - NOIDS_XMLPARSER = etree.XMLParser() + NOIDS_XMLPARSER = etree.XMLParser(resolve_entities="internal") def setdefault(object, attribute, producer): @@ -96,7 +96,8 @@ def XML(file, no_network=True, load_dtd=True): """ parser = etree.XMLParser(remove_blank_text=True, load_dtd=load_dtd, - no_network=no_network) + no_network=no_network, + resolve_entities="internal") xml = etree.parse(file, parser) return xml diff --git a/requirements.txt b/requirements.txt index 86bdb36f0..a331d5302 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ urllib3==2.2.2 -lxml==4.9.3 +lxml==6.1.1 Pillow==10.1.0 minio==7.2.0 requests==2.32.0 diff --git a/setup.py b/setup.py index cdad8859a..b6f6edbbd 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ def run(self): INSTALL_REQUIRES = [ 'aiohttp>=3.9.1', - 'lxml>=4.9.2', + 'lxml>=6.1.0', 'langcodes>=3.3.0', 'langdetect>=1.0.9', 'picles.plumber>=0.11', diff --git a/tox.ini b/tox.ini index 4c69d6210..d20f45278 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = {py39,py310,py311}-lxml{492} +envlist = {py39,py310,py311}-lxml{611} [testenv] isolated_build=true @@ -8,7 +8,7 @@ basepython = py310: python3.10 py311: python3.11 deps = - lxml492: lxml==4.9.2 + lxml611: lxml==6.1.1 Flask-Testing flask-babel python-magic