From c6b7dc2d6d94c8fb71284d2e3bb32df1a92e9652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 15:30:07 -0300 Subject: [PATCH 1/2] fix(spreadsheet): close XLSX DTD pre-scan bypass via declared encodings The DTD pre-scan decoded the prolog as UTF-8/UTF-16 only, while expat honours any declared encoding (cp037, utf-32, latin-1...), so a DOCTYPE in such a member reached the parser. Reject prefixes that are not valid UTF-8/UTF-16, reject XML declarations with other encodings, and make expat itself raise FormatError on DOCTYPE/ENTITY declarations. Co-Authored-By: Claude Sonnet 5.5 --- docs/adr/0005-leitor-de-planilhas.md | 2 +- docs/specs/003b-fontes-planilhas/spec.md | 2 +- src/namescommons/spreadsheet.py | 121 ++++++++++++++++++++--- tests/test_spreadsheet.py | 98 +++++++++++++++++- 4 files changed, 206 insertions(+), 17 deletions(-) diff --git a/docs/adr/0005-leitor-de-planilhas.md b/docs/adr/0005-leitor-de-planilhas.md index bcc2f25..b97f23e 100644 --- a/docs/adr/0005-leitor-de-planilhas.md +++ b/docs/adr/0005-leitor-de-planilhas.md @@ -33,7 +33,7 @@ Opções avaliadas: ## Consequências - XLSX não depende de terceiros, e o pico de memória é o de uma linha mais os textos compartilhados. -- O `ElementTree` da biblioteca padrão não busca entidades externas, e o expat do Python 3.12 limita a expansão de entidades; por isso `defusedxml` não entra. Como defesa em profundidade, o leitor recusa com `FormatError` qualquer membro XML cujo prólogo contenha ` list[str]` devolve as abas na ordem do arquivo. `rows(path, sheet) -> Iterator[tuple[int, list]]` devolve `(número da linha, valores)` de cada linha não vazia, com o número da linha como o Excel mostra (a partir de 1) e os valores na posição da coluna (A = 0). Uma aba fora de `sheet_names` levanta `FormatError` com o nome pedido e a lista das abas. - **R1.2 Formato pelo conteúdo.** O formato é reconhecido pelos primeiros bytes, não pela extensão: `PK\x03\x04` é XLSX (ZIP do Office Open XML) e `D0 CF 11 E0` é XLS (documento composto do Excel, da versão 5 em diante). Outro conteúdo, inclusive uma página HTML de erro gravada no lugar do arquivo, levanta `FormatError`. - **R1.3 Valores iguais nos dois formatos.** Texto vira `str` sem alteração, número inteiro vira `int`, número com fração vira `float`, booleano vira `bool` e célula vazia vira `None`. Valores `None` no fim da linha são cortados, e linhas sem nenhum valor são puladas. Datas chegam como o número de série do Excel (nenhuma fonte de nomes usa datas em célula); a célula XLSX de tipo `d` (data ISO 8601, rara) não é aceita e levanta `FormatError` com o tipo. Em células mescladas, o valor fica só na célula do canto superior esquerdo, e as demais chegam vazias (`None`), como estão gravadas no arquivo. Célula com erro de fórmula (`#N/A`, `#REF!`) levanta `FormatError` com a aba e a referência da célula. -- **R1.4 XLSX com a biblioteca padrão.** O XLSX é lido com `zipfile` e `xml.etree.ElementTree.iterparse`, linha a linha, sem carregar a aba inteira na memória. São aceitos os tipos de célula `s` (texto compartilhado, inclusive com trechos de formatação; o texto fonético `rPh` é ignorado), `inlineStr`, `str`, `n` (ou sem tipo), `b` e `e`. A aba é localizada pelo nome em `xl/workbook.xml` e pelo alvo em `xl/_rels/workbook.xml.rels`. Cada linha lida sai da árvore XML, e a memória fica plana na aba inteira. Viram `FormatError`, com o arquivo e a aba ou a célula: membro XML com DTD no prólogo (` int | None` converte uma célula numa contagem pelo mesmo critério de `parse_count` (spec 003, R2.2): `int` não negativo passa, `float` com fração, negativo ou `bool` é `FormatError`, texto passa por `parse_count` com os marcadores de `missing`, e célula vazia só é aceita se `""` estiver em `missing`. diff --git a/src/namescommons/spreadsheet.py b/src/namescommons/spreadsheet.py index 3a02c9c..f723d03 100644 --- a/src/namescommons/spreadsheet.py +++ b/src/namescommons/spreadsheet.py @@ -4,11 +4,13 @@ XLS, com xlrd. Os dois devolvem valores do mesmo tipo: str, int, float, bool ou None. """ +import codecs import io import posixpath import re import zipfile import xml.etree.ElementTree as ET +import xml.parsers.expat as expat from collections.abc import Iterable, Iterator from pathlib import Path @@ -35,6 +37,10 @@ # Defesa em profundidade (ADR 0005): membro XML com DTD é recusado antes de ir ao parser. _MAX_PROLOGO = 1 << 16 _ESPACO = re.compile(r"[\s\ufeff]*") +_DECLARACAO = re.compile(r"<\?xml(?=\s)(.*?)\?>", re.DOTALL) +_ENCODING = re.compile(r"""\sencoding\s*=\s*(?:"([^"]*)"|'([^']*)')""") +_ENCODING_OK = re.compile(r"utf-?(?:8|16)", re.IGNORECASE) +_TAMANHO_BLOCO = 1 << 16 def _formato(caminho: Path) -> str: @@ -135,7 +141,11 @@ def __init__(self, fh, nome: str, caminho: Path): while True: parte = fh.read(4096) prefixo += parte - decisao = _prologo(prefixo) + try: + decisao = _prologo(prefixo, f"{caminho.name}: membro {nome}") + except FormatError: + fh.close() + raise if decisao == "dtd": fh.close() raise FormatError(f"{caminho.name}: membro {nome} tem DTD (DOCTYPE ou ENTITY); recusado") @@ -162,15 +172,47 @@ def close(self) -> None: super().close() -def _prologo(dados: bytes) -> str: - """'dtd' se há DOCTYPE/ENTITY antes do elemento raiz, 'raiz' se a raiz já começou, senão 'mais'.""" +def _texto_do_prefixo(dados: bytes, onde: str) -> str: + """Decodifica o prefixo como UTF-8 ou UTF-16 (por BOM ou NUL); outra coisa é FormatError. + + O expat aceita qualquer codificação declarada (EBCDIC, UTF-32, ISO-8859-x...) e um DOCTYPE + escrito assim passaria despercebido por uma leitura só em UTF-8; por isso o resto é recusado. + """ + if dados[:4] in (b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff") or dados[:4] in (b"<\x00\x00\x00", b"\x00\x00\x00<"): + raise FormatError(f"{onde} está em UTF-32; só UTF-8 e UTF-16 são aceitos") if dados[:2] in (b"\xff\xfe", b"\xfe\xff"): - texto = dados.decode("utf-16", errors="replace") + codec = "utf-16" + elif dados[:4] == b"<\x00?\x00": + codec = "utf-16-le" + elif dados[:4] == b"\x00<\x00?": + codec = "utf-16-be" elif b"\x00" in dados[:4]: - texto = dados.decode("utf-16-le" if dados[1:2] == b"\x00" else "utf-16-be", errors="replace") + raise FormatError(f"{onde} começa com bytes nulos que não são UTF-16; só UTF-8 e UTF-16 são aceitos") else: - # UTF-8 (BOM opcional). UTF-32 e EBCDIC não são inspecionados e falham no expat. - texto = dados.removeprefix(b"\xef\xbb\xbf").decode("utf-8", errors="replace") + codec = "utf-8-sig" + try: + # final=False: o prefixo pode terminar no meio de um caractere de vários bytes. + return codecs.getincrementaldecoder(codec)("strict").decode(dados, final=False) + except UnicodeDecodeError: + raise FormatError(f"{onde} não é UTF-8 nem UTF-16 válido; só essas codificações são aceitas") from None + + +def _confere_declaracao(texto: str, onde: str) -> None: + """A declaração `` só pode dizer UTF-8 ou UTF-16 (ou não dizer nada).""" + m = _DECLARACAO.match(texto) + if m and (e := _ENCODING.search(m.group(0))): + nome = e.group(1) if e.group(1) is not None else e.group(2) + if not _ENCODING_OK.fullmatch(nome): + raise FormatError(f"{onde} declara a codificação {nome!r}; só UTF-8 e UTF-16 são aceitos") + + +def _prologo(dados: bytes, onde: str = "membro XML") -> str: + """'dtd' se há DOCTYPE/ENTITY antes do elemento raiz, 'raiz' se a raiz já começou, senão 'mais'. + + FormatError se o prefixo não é UTF-8 nem UTF-16 ou se a declaração XML pede outra codificação. + """ + texto = _texto_do_prefixo(dados, onde) + _confere_declaracao(texto.lstrip("\ufeff"), onde) i = 0 while True: i = _ESPACO.match(texto, i).end() @@ -191,13 +233,70 @@ def _prologo(dados: bytes) -> str: i = fim + salto +def _eventos(fh, nome: str, caminho: Path, events=("end",)) -> Iterator[tuple[str, ET.Element]]: + """Eventos ("start"/"end", elemento) do XML de um membro, em fluxo, num expat que recusa DTD. + + Defesa em profundidade, independente da pré-checagem do prólogo (ADR 0005): o próprio expat + levanta FormatError ao ver DOCTYPE ou ENTITY, em qualquer codificação que ele aceite. O + `ET.XMLParser` em C não expõe o expat; por isso o expat é usado direto, com `ET.TreeBuilder`. + Erro de sintaxe vira `ET.ParseError`, como no `iterparse`. + """ + def recusa(*_args): + raise FormatError(f"{caminho.name}: membro {nome} tem DTD (DOCTYPE ou ENTITY); recusado") + + construtor = ET.TreeBuilder() + pendentes: list[tuple[str, ET.Element]] = [] + quer_start, quer_end = "start" in events, "end" in events + + def inicio(tag, atributos): + el = construtor.start(tag, atributos) + if quer_start: + pendentes.append(("start", el)) + + def fim(tag): + el = construtor.end(tag) + if quer_end: + pendentes.append(("end", el)) + + parser = expat.ParserCreate(namespace_separator="}") + parser.buffer_text = True + parser.StartDoctypeDeclHandler = recusa + parser.EntityDeclHandler = recusa + parser.StartElementHandler = lambda tag, atr: inicio(_tag(tag), {_tag(k): v for k, v in atr.items()}) + parser.EndElementHandler = lambda tag: fim(_tag(tag)) + parser.CharacterDataHandler = construtor.data + try: + while True: + bloco = fh.read(_TAMANHO_BLOCO) + parser.Parse(bloco, not bloco) + yield from pendentes + pendentes.clear() + if not bloco: + break + except expat.ExpatError as e: + raise ET.ParseError(str(e)) from None + + +def _tag(nome: str) -> str: + """'uri}local' do expat vira '{uri}local' (nome qualificado do ElementTree).""" + return "{" + nome if "}" in nome else nome + + +def _raiz(fh, nome: str, caminho: Path) -> ET.Element: + """Elemento raiz do XML de um membro (o `ET.parse(...).getroot()` com DTD recusado).""" + raiz = None + for _, el in _eventos(fh, nome, caminho): + raiz = el + return raiz + + def _xlsx_abas(z: zipfile.ZipFile, caminho: Path) -> list[tuple[str, str]]: """(nome, membro do ZIP) de cada aba, na ordem de xl/workbook.xml.""" try: with _membro(z, "xl/workbook.xml", caminho) as fh: - livro = ET.parse(fh).getroot() + livro = _raiz(fh, "xl/workbook.xml", caminho) with _membro(z, "xl/_rels/workbook.xml.rels", caminho) as fh: - rels = ET.parse(fh).getroot() + rels = _raiz(fh, "xl/_rels/workbook.xml.rels", caminho) except ET.ParseError as e: raise FormatError(f"{caminho.name}: workbook do XLSX ilegível: {e}") from None alvos = {r.get("Id"): r.get("Target", "") for r in rels.iter(_REL_PKG + "Relationship")} @@ -228,7 +327,7 @@ def _textos_compartilhados(z: zipfile.ZipFile, caminho: Path) -> list[str]: textos = [] with _membro(z, "xl/sharedStrings.xml", caminho) as fh: try: - for _, el in ET.iterparse(fh): + for _, el in _eventos(fh, "xl/sharedStrings.xml", caminho): if el.tag == _SI: textos.append(_texto(el)) el.clear() @@ -301,7 +400,7 @@ def _xlsx_rows(caminho: Path, sheet: str) -> Iterator[tuple[int, list]]: with _membro(z, membro, caminho) as fh: numero, pai = 0, None try: - for evento, el in ET.iterparse(fh, events=("start", "end")): + for evento, el in _eventos(fh, membro, caminho, ("start", "end")): if evento == "start": if el.tag == _SHEET_DATA: pai = el diff --git a/tests/test_spreadsheet.py b/tests/test_spreadsheet.py index 4aad4c7..c9c9399 100644 --- a/tests/test_spreadsheet.py +++ b/tests/test_spreadsheet.py @@ -312,17 +312,16 @@ def test_linhas_lidas_saem_da_arvore(tmp_path): dados = "".join(f'{i}' for i in range(1, 2001)) arq = _xlsx(tmp_path / "a.xlsx", [("X", dados)]) vistos = [] - original = planilha.ET.iterparse + original = planilha._eventos def espia(*args, **kwargs): - it = original(*args, **kwargs) - for evento, el in it: + for evento, el in original(*args, **kwargs): yield evento, el if evento == "end" and el.tag.endswith("sheetData"): vistos.append(len(el)) monkeypatch = pytest.MonkeyPatch() - monkeypatch.setattr(planilha.ET, "iterparse", espia) + monkeypatch.setattr(planilha, "_eventos", espia) try: assert sum(1 for _ in rows(arq, "X")) == 2000 finally: @@ -438,3 +437,94 @@ def test_r1_4_dtd_depois_de_bom_utf8_e_recusado(tmp_path, membro, operacao, prol arq = _regravar(base, tmp_path / "a.xlsx", membro, f'{prolog}{raiz}') with pytest.raises(FormatError, match=rf"a\.xlsx: membro {membro} tem DTD"): operacao(arq) + + +# DTD em codificações que fogem da pré-checagem em UTF-8 (parser differential) ---------------------- + +_MEMBROS = [ + ("xl/workbook.xml", lambda a: sheet_names(a)), + ("xl/_rels/workbook.xml.rels", lambda a: sheet_names(a)), + ("xl/sharedStrings.xml", lambda a: list(rows(a, "X"))), + ("xl/worksheets/sheet1.xml", lambda a: list(rows(a, "X"))), +] +_RAIZ = {"xl/workbook.xml": f"", "xl/_rels/workbook.xml.rels": "", + "xl/sharedStrings.xml": f"", "xl/worksheets/sheet1.xml": f""} +_DTD = ']>' + + +def _regravar_bytes(origem: Path, destino: Path, membro: str, dados: bytes) -> Path: + with zipfile.ZipFile(origem) as zin, zipfile.ZipFile(destino, "w", zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + zout.writestr(info.filename, dados if info.filename == membro else zin.read(info.filename)) + return destino + + +@pytest.mark.parametrize("membro, operacao", _MEMBROS) +@pytest.mark.parametrize("declarada, codec", [ + ("cp037", "cp037"), + ("utf-32", "utf-32"), + ("UTF-32LE", "utf-32-le"), + ("UTF-32BE", "utf-32-be"), + ("latin-1", "latin-1"), + ("iso-8859-1", "ascii"), + ("windows-1252", "cp1252"), + ("us-ascii", "ascii"), +]) +def test_r1_4_dtd_em_codificacao_nao_utf_e_recusado(tmp_path, membro, operacao, declarada, codec): + base = _xlsx(tmp_path / "base.xlsx", [("X", '1')]) + texto = f'{_DTD}{_RAIZ[membro]}' + arq = _regravar_bytes(base, tmp_path / "a.xlsx", membro, texto.encode(codec)) + with pytest.raises(FormatError, match=rf"a\.xlsx: membro {membro} "): + operacao(arq) + + +@pytest.mark.parametrize("membro, operacao", _MEMBROS) +@pytest.mark.parametrize("dados", [ + '{dtd}{raiz}'.encode("cp037"), # sem declaração: EBCDIC não é UTF-8 + '{dtd}{raiz}'.encode("utf-32-le"), + '{dtd}{raiz}'.encode("utf-32-be"), + b"\xff\xfe\x00\x00<\x00\x00\x00", + b"\x00\x00\xfe\xff\x00\x00\x00<", + b'\xe9{dtd}{raiz}', # UTF-8 inválido +], ids=["ebcdic", "utf32le", "utf32be", "bom-utf32le", "bom-utf32be", "utf8-invalido"]) +def test_r1_4_prefixo_que_nao_e_utf8_nem_utf16_e_recusado(tmp_path, membro, operacao, dados): + base = _xlsx(tmp_path / "base.xlsx", [("X", '1')]) + arq = _regravar_bytes(base, tmp_path / "a.xlsx", membro, dados) + with pytest.raises(FormatError, match=rf"a\.xlsx: membro {membro} "): + operacao(arq) + + +@pytest.mark.parametrize("membro, operacao", _MEMBROS) +@pytest.mark.parametrize("codec, declarada", [ + ("utf-8", "UTF-8"), ("utf-8", "utf8"), ("utf-8", None), ("utf-16", "UTF-16"), ("utf-16", "utf16"), +]) +def test_r1_4_dtd_em_utf8_e_utf16_com_declaracao_valida_continua_recusado(tmp_path, membro, operacao, codec, declarada): + base = _xlsx(tmp_path / "base.xlsx", [("X", '1')]) + enc = f' encoding="{declarada}"' if declarada else "" + texto = f'{_DTD}{_RAIZ[membro]}' + arq = _regravar_bytes(base, tmp_path / "a.xlsx", membro, texto.encode(codec)) + with pytest.raises(FormatError, match=rf"a\.xlsx: membro {membro} tem DTD"): + operacao(arq) + + +@pytest.mark.parametrize("codec, declarada", [ + ("utf-8", "UTF-8"), ("utf-8", "utf-8"), ("utf-16", "UTF-16"), ("utf-16-le", None)]) +def test_r1_4_xlsx_normal_em_utf8_ou_utf16_e_lido(tmp_path, codec, declarada): + base = _xlsx(tmp_path / "base.xlsx", [("X", "")]) + enc = f' encoding="{declarada}"' if declarada else "" + texto = (f'' + 'IÑIGO') + arq = _regravar_bytes(base, tmp_path / "a.xlsx", "xl/worksheets/sheet1.xml", texto.encode(codec)) + assert list(rows(arq, "X")) == [(1, ["IÑIGO"])] + + +@pytest.mark.parametrize("membro, operacao", _MEMBROS) +def test_r1_4_o_parser_recusa_dtd_mesmo_sem_a_pre_checagem(tmp_path, monkeypatch, membro, operacao): + """Defesa em profundidade: com a pré-checagem desligada, os handlers do expat ainda recusam DTD.""" + import namescommons.spreadsheet as planilha + monkeypatch.setattr(planilha, "_prologo", lambda dados, onde="": "raiz") + base = _xlsx(tmp_path / "base.xlsx", [("X", '1')]) + for dtd in (_DTD, BILLION_LAUGHS.split("')): + arq = _regravar(base, tmp_path / "a.xlsx", membro, f'{dtd}{_RAIZ[membro]}') + with pytest.raises(FormatError, match=rf"a\.xlsx: membro {membro} tem DTD"): + operacao(arq) From eaaa4d13a21801c6d39cbe10305bcf8be93f9de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 15:30:21 -0300 Subject: [PATCH 2/2] docs(adr): align ADR 0005 decision with the expat-based XLSX reader Co-Authored-By: Claude Sonnet 5.5 --- docs/adr/0005-leitor-de-planilhas.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0005-leitor-de-planilhas.md b/docs/adr/0005-leitor-de-planilhas.md index b97f23e..d89cfb4 100644 --- a/docs/adr/0005-leitor-de-planilhas.md +++ b/docs/adr/0005-leitor-de-planilhas.md @@ -25,7 +25,7 @@ Opções avaliadas: ## Decisão -- O módulo `namescommons.spreadsheet` lê XLSX com a biblioteca padrão (`zipfile` e `iterparse`), em fluxo. +- O módulo `namescommons.spreadsheet` lê XLSX com a biblioteca padrão (`zipfile` e `xml.parsers.expat` com `TreeBuilder`), em fluxo. - XLS é lido com `xlrd`, fixado em `==2.0.2` em `pyproject.toml`. É a única dependência de runtime acrescentada. - O formato é reconhecido pelos primeiros bytes do arquivo, e os dois caminhos devolvem valores do mesmo tipo (texto, inteiro, fração, booleano, vazio). - Adaptadores não importam `xlrd` nem leem XML de planilha: usam só `namescommons.spreadsheet`.