Skip to content

fix(security): bump lxml para 6.1.1, corrige XXE de leitura de arquivo local (fase 4, #1251) - #1283

Open
Rossi-Luciano wants to merge 1 commit into
scieloorg:masterfrom
Rossi-Luciano:chore/1251-phase4-lxml
Open

fix(security): bump lxml para 6.1.1, corrige XXE de leitura de arquivo local (fase 4, #1251)#1283
Rossi-Luciano wants to merge 1 commit into
scieloorg:masterfrom
Rossi-Luciano:chore/1251-phase4-lxml

Conversation

@Rossi-Luciano

@Rossi-Luciano Rossi-Luciano commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

O que esse PR faz?

Fase 4 da estratégia de atualização de dependências definida na issue #1251: lxml 4.9.3 → 6.1.1. Prioridade elevada em relação às fases anteriores: não é só uma CVE rotineira, é uma vulnerabilidade ativa neste repositório.

Vulnerabilidade corrigida (PYSEC-2026-87)

lxml < 6.1.0 resolve entidades externas por padrão (resolve_entities=True), permitindo que um XML não confiável leia arquivos locais via <!ENTITY xxe SYSTEM "file://...">.

Confirmei empiricamente que este repositório está exposto: um XML malicioso processado por packtools.XML() (o parser principal, com load_dtd=True, no_network=True) conseguia vazar o conteúdo de um arquivo local arbitrário. no_network só bloqueia esquemas de rede (http/ftp), não file://. Como o packtools processa pacotes XML de terceiros por definição, esse é um vetor de exfiltração de arquivo local ativo, não teórico.

Correções 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 explícito: resolve_entities="internal" adicionado nos 5 pontos onde XMLParser() é instanciado (packtools/utils.py, packtools/sps/pid_provider/xml_loader.py, packtools/sps/utils/xml_utils.py), para não depender só do default da biblioteca. Escolhi 'internal' (não False) porque preserva a resolução de entidades internas (ex.: entidades de caractere definidas inline no DOCTYPE) e bloqueia só as externas: testei e confirmei que resolve_entities=False quebraria entidades internas legítimas.
  3. setup.py: piso de lxml>=4.9.2 para lxml>=6.1.0. Isso é uma exceção pontual à regra geral de deixar setup.py para a fase 8 (sincronizar bounds), justificada pela gravidade: manter o piso solto deixaria pip install packtools (sem passar por requirements.txt) potencialmente vulnerável mesmo depois deste fix.
  4. tox.ini: fator lxml492lxml611, para não testar mais deliberadamente contra uma versão do lxml com CVE conhecida em aberto.

Regressão real encontrada e corrigida (não relacionada a segurança)

Rodar a suíte completa com lxml 6.1.1 sozinho (sem o endurecimento acima) revelou 15 falhas novas em packtools/sps/utils/xml_utils.py e test_article_titles.py, além do flake já conhecido de test_i18n.py.

Isolei a causa com uma reprodução mínima 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 após um <xref> antes de parent.remove(xref) descartá-lo) dependia desse efeito colateral implícito do lxml 4.x para funcionar. Corrigi movendo o tail explicitamente (e.tail = xref.tail; xref.tail = None) antes do addnext(), o que funciona identicamente em ambas as versões (validei nas duas).

Onde a revisão poderia começar?

  • packtools/sps/utils/xml_utils.py, função process_xref(): o fix da regressão de tail.
  • Os 5 pontos com resolve_entities="internal" novo.
  • setup.py/tox.ini: mudança de piso, fora do escopo usual desta fase, mas justificada acima.

Como este poderia ser testado manualmente?

from lxml import etree
import packtools, tempfile, os

secret = "conteudo-secreto"
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
    f.write(secret)
    path = f.name

xml = f'''<?xml version="1.0"?>
<!DOCTYPE article [ <!ENTITY xxe SYSTEM "file://{path}"> ]>
<article><body>&xxe;</body></article>
'''.encode()

with tempfile.NamedTemporaryFile("wb", suffix=".xml", delete=False) as xf:
    xf.write(xml)
    xml_path = xf.name

tree = packtools.XML(xml_path, no_network=True)  # deve levantar XMLSyntaxError após o fix

Antes deste PR (lxml 4.9.3): retorna o conteúdo do arquivo local. Depois (lxml 6.1.1 + resolve_entities="internal"): levanta XMLSyntaxError: Entity 'xxe' not defined.

pytest -q deve dar o mesmo resultado do master (40 failed pré-existentes, 5973 passed, 30 skipped).

Algum cenário de contexto que queira dar?

Independente das fases 1-3 (PR #1280/#1281/#1282): parte direto do master.

Validação: pytest completo com venv isolado (só lxml + os 3 arquivos de código alterados, resto igual ao master). Antes do fix do process_xref: 55 failed (a regressão real). Depois: duas rodadas deram 40 failed / 5973 passed / 30 skipped, idêntico ao baseline.

Também rodei tox -e py310-lxml611 (único Python 3.9/3.10/3.11 disponível localmente): 23 failures + 19 errors, sem relação com este bump de lxml. Correção (2026-08-16, adicionada após a fase 8/#1287): originalmente atribuí esse resultado a "Pillow sem pin em setup.py", isso estava errado. Investigando a fundo na fase 8, descobri que são exatamente as mesmas 40 falhas pré-existentes da issue #1276 (18 do htmlgenerator + 22 outras), só contadas de forma diferente pelo unittest, mais 2 ERROR espúrios causados por um bug de descoberta de testes em tox.ini (python -m unittest -vvv tentando importar packtools/sps/sps_versions/sps-1.9/sps-1.10 como módulo de teste). Ambos corrigidos no PR #1287.

Screenshots

N/A (mudança de dependência + parsing XML, sem interface).

Quais são os tickets relevantes?

Parte de #1251 (fase 4 da estratégia de atualização de dependências). Corrige PYSEC-2026-87.

Referências

Estratégia priorizada descrita nos comentários de progresso da issue #1251. Advisory: https://github.com/lxml/lxml/security/advisories (PYSEC-2026-87, GHSA correspondente).


Segurança da informação (NSI.04)

Este PR manipula dados sensíveis ou pessoais (LGPD)?

  • Sim
  • Não

Este PR altera autenticação, autorização, controle de acesso ou gerenciamento de sessão?

  • Sim
  • Não

Este PR introduz, atualiza ou remove dependências de terceiros?

  • Sim — as novas dependências foram verificadas no SBOM/Trivy sem vulnerabilidades críticas/altas em aberto?

    • Verificado e aprovado
    • Pendente / vulnerabilidade aceita com justificativa:

    Este repositório não usa Trivy/SBOM (é biblioteca, não serviço containerizado, conforme SECURITY_ADHERENCE.md seção 3). O gate real de dependências é o Snyk, que roda automaticamente neste PR. Verifiquei manualmente com pip-audit e confirmei empiricamente (ver seção acima) que a versão-alvo corrige a CVE PYSEC-2026-87 ativa nesta versão do lxml.

  • Não

Este PR foi validado pelo pipeline de segurança (SonarQube / Trivy)?

  • Sim
  • Não aplicável a este PR (justifique): SonarQube e Trivy não estão configurados neste repositório (SECURITY_ADHERENCE.md seção 3). Os gates automáticos reais (Snyk e GitGuardian) rodam neste PR.

Este PR concatena, monta ou executa comandos SQL, HTML ou JavaScript a partir de entrada externa?

  • Sim
  • Não, mas é adjacente: este PR trata diretamente de uma classe de vulnerabilidade de parsing de XML não confiável (XXE/entidade externa, PYSEC-2026-87), não SQL/HTML/JS. Ver a seção "O que esse PR faz?" acima para os detalhes e a prova de conceito.

Este PR expõe novos endpoints, telas ou serviços?

  • Sim
  • Não

Algum segredo, senha, chave ou token está sendo adicionado ao código-fonte?

  • Não, nenhum segredo foi commitado
  • Sim

…o local (fase 4, scieloorg#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 <!ENTITY xxe SYSTEM "file://...">. 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 <xref> 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 scieloorg#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants