Skip to content

Sanitiza HTML/JavaScript nos campos TextField (stored XSS) - #3846

Merged
edwardoliveira merged 6 commits into
3.1.xfrom
hotfix/sanitize-textfield-xss
Oct 14, 2026
Merged

edwardoliveira merged 6 commits into
3.1.xfrom
hotfix/sanitize-textfield-xss

Conversation

@edwardoliveira

Copy link
Copy Markdown
Contributor

Problema

Os ~71 campos models.TextField do SAPL guardavam texto livre sem nenhuma sanitização, e a camada de renderização os tratava como HTML confiável em 142 pontos (|safe, {% autoescape off %}).

Resultado: stored XSS. Um usuário com permissão de edição grava <script> em TramitacaoAdministrativo.texto, MateriaLegislativa.ementa, Parlamentar.biografia etc. e o código executa no navegador de qualquer pessoa que abra a tela — inclusive nas páginas públicas (acompanhamento_materia, acompanhamento_documento, parlamentar_perfil_publico, resumo de sessão).

Dois agravantes encontrados durante a análise:

  • O sink principal não é um template, é uma função: get_field_display() (sapl/crispy_layout_mixin.py) monta o HTML de todo TextField e é consumida com |safe por crud/detail.html, crud/detail_detail.html e crud/list.html — praticamente toda tela de listagem/detalhe do sistema.
  • Cinco |safe estavam dentro de <textarea> (telas de votação), onde </textarea><script> escapa do contexto.

Solução

Duas camadas, ambas chamando a mesma função idempotente — é essa propriedade que permite aplicar as duas sem que os efeitos se acumulem:

Camada Onde Cobre
Entrada receiver pre_save global em sapl/base/receivers.py forms, API do drfautoapi, admin e shell
Saída get_field_display() + novo filtro |sanitize as linhas gravadas antes desta mudança

Não há migração de dados: as linhas existentes ficam neutralizadas na renderização e são limpas quando o registro for salvo de novo.

Políticas por campo (sapl/sanitize.py)

  • plain (padrão para todo TextField) — remove toda a marcação e preserva o texto. <b>oi</b> vira oi; < e & saem corretamente codificados.
  • rich — allowlist para os campos editados no TinyMCE: ExpedienteSessao.conteudo, OcorrenciaSessao.conteudo, ConsideracoesFinais.conteudo, Dispositivo.texto/texto_atualizador e os fragmentos *_html de TipoDispositivo.
  • exempt — LexmlProvedor.xml (é XML, já escapado em pretty_xml) e TipoTextoArticulado.rodape_global (vai para um content: de CSS).

Links continuam funcionando

Entrada Saída
<a href="https://camara.gov.br">Portal</a> inalterado
<a href="/materia/123">Matéria</a> inalterado
<a href="javascript:alert(1)">clique</a> <a>clique</a> — texto mantido, URL descartada
<a href="#" onclick="steal()">x</a> <a href="#">x</a>
<script>…</script> removido junto com o conteúdo

target="_blank" e o style="text-align: …" dos botões de alinhamento do TinyMCE estão na allowlist de propósito, para não regredir o conteúdo já cadastrado. O rel="noopener noreferrer" passa a ser escrito automaticamente nos links.

Também corrigido

  • 5 |safe dentro de <textarea> (sessao/votacao/*).
  • 5 |striptags|safe — a documentação do Django avisa que striptags não garante HTML seguro, e o |safe desligava o autoescape sobre a sobra.
  • 3 {% autoescape off %} nos relatórios de matérias/normas por autor.

Dependência

nh3==0.2.22 (binding Rust do ammonia, sem dependências Python, wheels manylinux/macOS para cp312 — a imagem é python:3.12-slim-bookworm). É a substituta recomendada pelo bleach, hoje em modo de manutenção.

Testes

sapl/base/tests/test_sanitize.py — 23 testes, todos passando. Cobrem a idempotência das duas políticas, a preservação de links/formatação nos campos ricos, a remoção de script/on*/javascript:, o comportamento do pre_save, as isenções, e a proteção de uma linha legada gravada direto via .update().

Suíte completa: 22 falhas antes, 22 falhas depois, os mesmos testes — nenhuma regressão. (As falhas pré-existentes vêm de test_urls.py, redireciona_urls e forms de casa legislativa/audiência, sem relação com esta mudança.)

flake8 nos arquivos novos está limpo; nos arquivos tocados a contagem de violações pré-existentes continua idêntica (48 → 48).

Verificação manual sugerida

  1. Gravar <script>alert(1)</script>Teste em TramitacaoAdministrativo.texto e abrir o detalhe do documento e o filtro de matérias.
  2. Inserir um link pelo TinyMCE num expediente, salvar e reabrir — deve continuar clicável e abrindo em nova aba.
  3. Usar o botão code do TinyMCE para colar <a href="javascript:alert(1)">x</a><script>alert(2)</script> e conferir o resumo público da sessão.
  4. Abrir um expediente pré-existente com centralização e tabela e confirmar que está idêntico.
  5. Gerar a ata e a pauta em PDF da mesma sessão.
  6. Abrir e editar um texto articulado com dispositivos alterados.

Fora de escopo

  • relatorios/views.py e painel/views.py chamam html.unescape() em ementa/observacao/conteudo. Não é vetor de XSS (o destino é PDF/painel), mas revela que essas colunas já contêm entidades HTML hoje.
  • relatorios/base_relatorio.html interpola rodape dentro de um content: de CSS — contexto diferente, correção diferente.
  • O plugin code do TinyMCE expõe uma caixa de HTML bruto e é o caminho de injeção mais direto do sistema; desabilitá-lo reduziria a superfície, mas mexe no build do frontend.
  • sapl/crud/tests/test_base.py está com erro de sintaxe (import truncado) desde d08fb9e e não foi tocado aqui.

🤖 Generated with Claude Code

Os ~71 campos models.TextField do SAPL guardavam texto livre sem nenhuma
sanitização, e a renderização os tratava como HTML confiável em 142 pontos
(|safe, {% autoescape off %}). Qualquer usuário com permissão de edição
conseguia gravar <script> num campo e executá-lo no navegador de quem
abrisse a tela, inclusive nas páginas públicas (acompanhamento de matéria,
perfil público de parlamentar, resumo de sessão).

Duas camadas, ambas usando a mesma função idempotente para que não se
acumulem:

- Entrada: receiver pre_save global em sapl/base/receivers.py, cobrindo
  forms, a API do drfautoapi, o admin e o shell num único ponto.
- Saída: get_field_display() em sapl/crispy_layout_mixin.py — que monta o
  HTML de todo TextField para as telas de list/detail do CRUD — mais o
  novo filtro |sanitize nos templates. Protege as linhas gravadas antes
  desta mudança, já que não há backfill.

Políticas por campo, em sapl/sanitize.py:

- plain (padrão): remove toda a marcação e preserva o texto.
- rich: allowlist para os campos editados no TinyMCE (conteúdo de
  expediente, ocorrências, considerações finais e texto de dispositivo).
  Links continuam funcionando, com target e o alinhamento do editor
  preservados; javascript:, data:, on* e <script> são descartados.
- exempt: LexmlProvedor.xml e TipoTextoArticulado.rodape_global, que não
  são HTML.

Corrige também cinco |safe dentro de <textarea>, onde
</textarea><script> escapava do contexto, e cinco |striptags|safe — a
documentação do Django avisa que striptags não garante HTML seguro.

Sem migração de dados: as linhas existentes são neutralizadas na
renderização e limpas quando o registro for salvo de novo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread sapl/sanitize.py Resolved
Comment thread sapl/sanitize.py Resolved
Comment thread sapl/base/receivers.py Resolved
Comment thread sapl/sanitize.py Resolved
edwardoliveira and others added 5 commits September 28, 2026 15:42
Ajustes da revisão do #3846:

- plain: grava texto puro (entidades decodificadas); o escape fica na
  renderização. Antes, & e < apareciam como &amp; e &lt; na tela e na API.
- get_field_display: escapa campos plain e isentos em vez de re-sanitizar.
- Blocos da ata: |sanitize|striptags, sem escapar &nbsp;/&amp; duas vezes.
- RICH_TEXT_FIELDS: inclui os campos editados no TinyMCE que faltavam
  (biografia, informacao_geral, NormaRelacionada.resumo,
  Legislatura.observacao); perfil público renderiza biografia com |sanitize.
- compilacao: Dispositivo.texto passa de |safe para |sanitize nos cinco
  templates restantes. TipoDispositivo.*_html continua na política rich no
  pre_save; o form de edição rejeita fragmentos com tags desbalanceadas,
  que a sanitização descartaria ao salvar.
- pre_save ignora raw=True (loaddata, inclusive em migrations).
- Isenção por campo (SANITIZE_EXEMPT_FIELDS), como RICH_TEXT_FIELDS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@edwardoliveira
edwardoliveira merged commit 37a248c into 3.1.x Oct 14, 2026
2 checks passed
@edwardoliveira
edwardoliveira deleted the hotfix/sanitize-textfield-xss branch October 14, 2026 01:37
@joaohortsenado joaohortsenado mentioned this pull request Nov 4, 2026
9 tasks
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