Repository navigation
Sanitiza HTML/JavaScript nos campos TextField (stored XSS) - #3846
Merged
Merged
Conversation
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>
joaohortsenado
self-requested a review
August 3, 2026 18:35
joaohortsenado
requested changes
Aug 3, 2026
Ajustes da revisão do #3846: - plain: grava texto puro (entidades decodificadas); o escape fica na renderização. Antes, & e < apareciam como & e < 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 /& 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>
6 of 9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problema
Os ~71 campos
models.TextFielddo 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>emTramitacaoAdministrativo.texto,MateriaLegislativa.ementa,Parlamentar.biografiaetc. 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:
get_field_display()(sapl/crispy_layout_mixin.py) monta o HTML de todoTextFielde é consumida com|safeporcrud/detail.html,crud/detail_detail.htmlecrud/list.html— praticamente toda tela de listagem/detalhe do sistema.|safeestavam 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:
pre_saveglobal emsapl/base/receivers.pydrfautoapi, admin e shellget_field_display()+ novo filtro|sanitizeNã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 todoTextField) — remove toda a marcação e preserva o texto.<b>oi</b>viraoi;<e&saem corretamente codificados.rich— allowlist para os campos editados no TinyMCE:ExpedienteSessao.conteudo,OcorrenciaSessao.conteudo,ConsideracoesFinais.conteudo,Dispositivo.texto/texto_atualizadore os fragmentos*_htmldeTipoDispositivo.exempt—LexmlProvedor.xml(é XML, já escapado empretty_xml) eTipoTextoArticulado.rodape_global(vai para umcontent:de CSS).Links continuam funcionando
<a href="https://camara.gov.br">Portal</a><a href="/materia/123">Matéria</a><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>target="_blank"e ostyle="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. Orel="noopener noreferrer"passa a ser escrito automaticamente nos links.Também corrigido
|safedentro de<textarea>(sessao/votacao/*).|striptags|safe— a documentação do Django avisa questriptagsnão garante HTML seguro, e o|safedesligava o autoescape sobre a sobra.{% 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 pelobleach, 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 descript/on*/javascript:, o comportamento dopre_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_urlse forms de casa legislativa/audiência, sem relação com esta mudança.)flake8nos arquivos novos está limpo; nos arquivos tocados a contagem de violações pré-existentes continua idêntica (48 → 48).Verificação manual sugerida
<script>alert(1)</script>TesteemTramitacaoAdministrativo.textoe abrir o detalhe do documento e o filtro de matérias.codedo TinyMCE para colar<a href="javascript:alert(1)">x</a><script>alert(2)</script>e conferir o resumo público da sessão.Fora de escopo
relatorios/views.pyepainel/views.pychamamhtml.unescape()emementa/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.htmlinterpolarodapedentro de umcontent:de CSS — contexto diferente, correção diferente.codedo 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.pyestá com erro de sintaxe (import truncado) desde d08fb9e e não foi tocado aqui.🤖 Generated with Claude Code