mirror of https://github.com/interlegis/sapl.git
Browse Source
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>
hotfix/sanitize-textfield-xss
50 changed files with 423 additions and 66 deletions
@ -0,0 +1,163 @@ |
|||||
|
import pytest |
||||
|
from model_bakery import baker |
||||
|
|
||||
|
from sapl.crispy_layout_mixin import get_field_display |
||||
|
from sapl.lexml.models import LexmlProvedor |
||||
|
from sapl.protocoloadm.models import TramitacaoAdministrativo |
||||
|
from sapl.sanitize import sanitize_field, sanitize_html, sanitize_scope |
||||
|
from sapl.sessao.models import ExpedienteSessao |
||||
|
|
||||
|
|
||||
|
def test_plain_remove_marcacao_e_preserva_texto(): |
||||
|
assert sanitize_html('<script>alert(1)</script>Ciente') == 'Ciente' |
||||
|
assert sanitize_html('Encaminhado <b>ao</b> setor') == \ |
||||
|
'Encaminhado ao setor' |
||||
|
assert sanitize_html('<img src=x onerror=alert(1)>') == '' |
||||
|
assert sanitize_html('<div>a</div><div>b</div>') == 'ab' |
||||
|
|
||||
|
|
||||
|
def test_plain_escapa_caracteres_especiais(): |
||||
|
assert sanitize_html('Valor < 10 & prazo > 5') == \ |
||||
|
'Valor < 10 & prazo > 5' |
||||
|
|
||||
|
|
||||
|
def test_plain_preserva_quebras_de_linha(): |
||||
|
# get_field_display converte \n em <br/> depois de sanitizar |
||||
|
assert sanitize_html('linha1\nlinha2') == 'linha1\nlinha2' |
||||
|
|
||||
|
|
||||
|
def test_valores_vazios_atravessam(): |
||||
|
assert sanitize_html('') == '' |
||||
|
assert sanitize_html(None) is None |
||||
|
assert sanitize_html('', rich=True) == '' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.parametrize('valor', [ |
||||
|
'<script>alert(1)</script>Ciente', |
||||
|
'Valor < 10 & prazo > 5', |
||||
|
'Encaminhado <b>ao</b> <a href="https://x">setor</a>', |
||||
|
'texto & cia', |
||||
|
]) |
||||
|
def test_plain_e_idempotente(valor): |
||||
|
"""Propriedade da qual dependem as duas camadas (pre_save + renderização). |
||||
|
|
||||
|
Se sanitizar duas vezes não fosse estável, o valor gravado seria |
||||
|
re-escapado a cada exibição. |
||||
|
""" |
||||
|
uma_vez = sanitize_html(valor) |
||||
|
assert sanitize_html(uma_vez) == uma_vez |
||||
|
|
||||
|
|
||||
|
@pytest.mark.parametrize('valor', [ |
||||
|
'<a href="https://camara.gov.br" target="_blank">Portal</a>', |
||||
|
'<p style="text-align: center;">centro</p>', |
||||
|
'<table><tr><td colspan="2">c</td></tr></table>', |
||||
|
'<script>alert(1)</script><b>ok</b>', |
||||
|
]) |
||||
|
def test_rich_e_idempotente(valor): |
||||
|
uma_vez = sanitize_html(valor, rich=True) |
||||
|
assert sanitize_html(uma_vez, rich=True) == uma_vez |
||||
|
|
||||
|
|
||||
|
def test_rich_preserva_links(): |
||||
|
saida = sanitize_html( |
||||
|
'<a href="https://camara.gov.br" target="_blank">Portal</a>', |
||||
|
rich=True) |
||||
|
assert 'href="https://camara.gov.br"' in saida |
||||
|
assert 'target="_blank"' in saida |
||||
|
assert 'rel="noopener noreferrer"' in saida |
||||
|
assert '>Portal</a>' in saida |
||||
|
|
||||
|
assert 'href="/materia/123"' in sanitize_html( |
||||
|
'<a href="/materia/123">Matéria</a>', rich=True) |
||||
|
assert 'href="mailto:a@b.c"' in sanitize_html( |
||||
|
'<a href="mailto:a@b.c">mail</a>', rich=True) |
||||
|
|
||||
|
|
||||
|
def test_rich_remove_href_perigosa_mas_mantem_o_texto(): |
||||
|
saida = sanitize_html( |
||||
|
'<a href="javascript:alert(1)">clique</a>', rich=True) |
||||
|
assert 'javascript' not in saida |
||||
|
assert 'clique' in saida |
||||
|
|
||||
|
|
||||
|
def test_rich_remove_script_e_manipuladores_de_evento(): |
||||
|
saida = sanitize_html('<script>alert(1)</script><b>ok</b>', rich=True) |
||||
|
assert saida == '<b>ok</b>' |
||||
|
|
||||
|
assert 'onclick' not in sanitize_html( |
||||
|
'<a href="#" onclick="steal()">x</a>', rich=True) |
||||
|
assert 'onerror' not in sanitize_html( |
||||
|
'<img src="x" onerror="alert(1)">', rich=True) |
||||
|
|
||||
|
|
||||
|
def test_rich_preserva_formatacao_do_tinymce(): |
||||
|
"""Protege contra regressão visual no conteúdo já cadastrado.""" |
||||
|
assert 'style="text-align: center;"' in sanitize_html( |
||||
|
'<p style="text-align: center;">centro</p>', rich=True) |
||||
|
|
||||
|
saida = sanitize_html( |
||||
|
'<table><tr><td colspan="2">c</td></tr></table>', rich=True) |
||||
|
assert '<table>' in saida and 'colspan="2"' in saida |
||||
|
|
||||
|
assert sanitize_html('<ul><li>a</li><li>b</li></ul>', rich=True) == \ |
||||
|
'<ul><li>a</li><li>b</li></ul>' |
||||
|
|
||||
|
|
||||
|
def test_sanitize_scope(): |
||||
|
assert sanitize_scope(TramitacaoAdministrativo, 'texto') == 'plain' |
||||
|
assert sanitize_scope(ExpedienteSessao, 'conteudo') == 'rich' |
||||
|
assert sanitize_scope(LexmlProvedor, 'xml') == 'exempt' |
||||
|
|
||||
|
|
||||
|
def test_sanitize_field_respeita_isencao(): |
||||
|
xml = '<xml><a href="javascript:x">y</a></xml>' |
||||
|
assert sanitize_field(LexmlProvedor, 'xml', xml) == xml |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db |
||||
|
def test_pre_save_sanitiza_campo_simples(): |
||||
|
t = baker.make(TramitacaoAdministrativo, |
||||
|
texto='<script>alert(1)</script>Ciente <b>ok</b>') |
||||
|
t.refresh_from_db() |
||||
|
assert t.texto == 'Ciente ok' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db |
||||
|
def test_pre_save_sanitiza_campo_rico_preservando_html(): |
||||
|
e = baker.make(ExpedienteSessao, |
||||
|
conteudo='<b>x</b><script>alert(1)</script>' |
||||
|
'<a href="https://a.b" target="_blank">l</a>') |
||||
|
e.refresh_from_db() |
||||
|
assert '<script>' not in e.conteudo |
||||
|
assert '<b>x</b>' in e.conteudo |
||||
|
assert 'href="https://a.b"' in e.conteudo |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db |
||||
|
def test_pre_save_nao_toca_modelo_isento(): |
||||
|
xml = '<xml>a & b <script>x</script></xml>' |
||||
|
p = baker.make(LexmlProvedor, xml=xml) |
||||
|
p.refresh_from_db() |
||||
|
assert p.xml == xml |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db |
||||
|
def test_get_field_display_nao_devolve_script(): |
||||
|
t = TramitacaoAdministrativo(texto='<script>alert(1)</script>Ciente') |
||||
|
__, display = get_field_display(t, 'texto') |
||||
|
assert '<script>' not in display |
||||
|
assert 'Ciente' in display |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db |
||||
|
def test_get_field_display_protege_linha_legada(): |
||||
|
"""Linhas gravadas antes do pre_save não passam pela camada de entrada.""" |
||||
|
t = baker.make(TramitacaoAdministrativo, texto='ok') |
||||
|
TramitacaoAdministrativo.objects.filter(pk=t.pk).update( |
||||
|
texto='<script>alert(1)</script>legado') |
||||
|
t.refresh_from_db() |
||||
|
assert t.texto == '<script>alert(1)</script>legado' |
||||
|
|
||||
|
__, display = get_field_display(t, 'texto') |
||||
|
assert '<script>' not in display |
||||
@ -0,0 +1,132 @@ |
|||||
|
"""Sanitização de HTML/JavaScript nos campos de texto livre do SAPL. |
||||
|
|
||||
|
Módulo sem dependências internas do SAPL de propósito: é importado por |
||||
|
``sapl.crispy_layout_mixin``, ``sapl.base.receivers`` e pelos templatetags, |
||||
|
e ``sapl.utils`` já importa ``sapl.crispy_layout_mixin``. |
||||
|
|
||||
|
Duas políticas: |
||||
|
|
||||
|
* ``plain`` — remove toda a marcação e preserva o texto. É o padrão para |
||||
|
qualquer ``TextField``. |
||||
|
* ``rich`` — allowlist para os campos editados via TinyMCE, que contêm HTML |
||||
|
legítimo (negrito, listas, tabelas e links). |
||||
|
|
||||
|
Ambas são idempotentes: aplicar duas vezes produz o mesmo resultado. É essa |
||||
|
propriedade que permite sanitizar tanto no ``pre_save`` quanto na renderização |
||||
|
sem que os efeitos se acumulem. |
||||
|
""" |
||||
|
|
||||
|
import nh3 |
||||
|
|
||||
|
SANITIZE_RICH_TAGS = { |
||||
|
'p', 'br', 'hr', 'div', 'span', |
||||
|
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'sub', 'sup', |
||||
|
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'pre', 'code', |
||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', |
||||
|
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', |
||||
|
'col', 'colgroup', |
||||
|
'a', 'img', |
||||
|
} |
||||
|
|
||||
|
# 'style' mantém o alinhamento produzido pelos botões do TinyMCE; |
||||
|
# 'target' mantém o "abrir em nova aba" dos links já cadastrados. |
||||
|
# 'rel' não pode entrar aqui: o nh3 aborta se a tag 'a' declarar 'rel' |
||||
|
# ao mesmo tempo em que link_rel está definido — ele mesmo escreve o atributo. |
||||
|
SANITIZE_RICH_ATTRS = { |
||||
|
'*': {'style', 'class', 'align', 'title', 'dir', 'lang'}, |
||||
|
'a': {'href', 'target', 'name'}, |
||||
|
'img': {'src', 'alt', 'width', 'height'}, |
||||
|
'td': {'colspan', 'rowspan', 'headers'}, |
||||
|
'th': {'colspan', 'rowspan', 'scope', 'headers'}, |
||||
|
'col': {'span'}, |
||||
|
'colgroup': {'span'}, |
||||
|
'table': {'border', 'cellpadding', 'cellspacing', 'summary'}, |
||||
|
} |
||||
|
|
||||
|
SANITIZE_URL_SCHEMES = {'http', 'https', 'mailto', 'tel'} |
||||
|
|
||||
|
# O conteúdo destas tags é descartado junto com a tag. Sem isso o texto de |
||||
|
# dentro de um <script> sobreviveria como texto solto. |
||||
|
SANITIZE_CLEAN_CONTENT_TAGS = {'script', 'style'} |
||||
|
|
||||
|
# Campos que guardam HTML legítimo, indexados por '<app_label>.<Model>'. |
||||
|
# Os de sessao e compilacao.Dispositivo são editados no TinyMCE; os de |
||||
|
# compilacao.TipoDispositivo são fragmentos de template configurados por |
||||
|
# administradores. |
||||
|
RICH_TEXT_FIELDS = { |
||||
|
'sessao.ExpedienteSessao': {'conteudo'}, |
||||
|
'sessao.OcorrenciaSessao': {'conteudo'}, |
||||
|
'sessao.ConsideracoesFinais': {'conteudo'}, |
||||
|
'compilacao.Dispositivo': {'texto', 'texto_atualizador'}, |
||||
|
'compilacao.TipoDispositivo': { |
||||
|
'rotulo_prefixo_html', 'rotulo_sufixo_html', |
||||
|
'texto_prefixo_html', 'texto_sufixo_html', |
||||
|
'nota_automatica_prefixo_html', 'nota_automatica_sufixo_html', |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
# Modelos cujos TextField não devem ser tocados em hipótese alguma. |
||||
|
SANITIZE_EXEMPT_MODELS = { |
||||
|
# xml é XML fornecido pela equipe do LexML; já é escapado em pretty_xml |
||||
|
'lexml.LexmlProvedor', |
||||
|
# rodape_global é interpolado dentro de um content: de CSS |
||||
|
'compilacao.TipoTextoArticulado', |
||||
|
} |
||||
|
|
||||
|
|
||||
|
def model_key(model): |
||||
|
return '{}.{}'.format(model._meta.app_label, model.__name__) |
||||
|
|
||||
|
|
||||
|
def sanitize_scope(model, fieldname): |
||||
|
"""Retorna 'exempt', 'rich' ou 'plain' para um campo de um modelo.""" |
||||
|
key = model_key(model) |
||||
|
if key in SANITIZE_EXEMPT_MODELS: |
||||
|
return 'exempt' |
||||
|
if fieldname in RICH_TEXT_FIELDS.get(key, ()): |
||||
|
return 'rich' |
||||
|
return 'plain' |
||||
|
|
||||
|
|
||||
|
def sanitize_html(value, rich=False): |
||||
|
"""Remove HTML/JavaScript perigoso de ``value``. |
||||
|
|
||||
|
Com ``rich=False`` toda a marcação é removida e apenas o texto sobra. |
||||
|
Com ``rich=True`` aplica-se a allowlist: links são preservados, mas |
||||
|
esquemas de URL fora de SANITIZE_URL_SCHEMES (javascript:, data:) e |
||||
|
manipuladores de evento (onclick, onerror) são descartados. |
||||
|
""" |
||||
|
if not value: |
||||
|
return value |
||||
|
|
||||
|
if not isinstance(value, str): |
||||
|
value = str(value) |
||||
|
|
||||
|
if rich: |
||||
|
return nh3.clean( |
||||
|
value, |
||||
|
tags=SANITIZE_RICH_TAGS, |
||||
|
attributes=SANITIZE_RICH_ATTRS, |
||||
|
url_schemes=SANITIZE_URL_SCHEMES, |
||||
|
clean_content_tags=SANITIZE_CLEAN_CONTENT_TAGS, |
||||
|
link_rel='noopener noreferrer', |
||||
|
strip_comments=True) |
||||
|
|
||||
|
return nh3.clean( |
||||
|
value, |
||||
|
tags=set(), |
||||
|
attributes={}, |
||||
|
clean_content_tags=SANITIZE_CLEAN_CONTENT_TAGS, |
||||
|
link_rel=None, |
||||
|
strip_comments=True) |
||||
|
|
||||
|
|
||||
|
def sanitize_field(model, fieldname, value): |
||||
|
"""Sanitiza ``value`` conforme a política do campo. |
||||
|
|
||||
|
Campos de modelos isentos atravessam sem modificação. |
||||
|
""" |
||||
|
scope = sanitize_scope(model, fieldname) |
||||
|
if scope == 'exempt': |
||||
|
return value |
||||
|
return sanitize_html(value, rich=(scope == 'rich')) |
||||
@ -1,4 +1,4 @@ |
|||||
<h2 class="gray-title">Considerações Finais</h2> |
<h2 class="gray-title">Considerações Finais</h2> |
||||
{% for c in lst_consideracoes%} |
{% for c in lst_consideracoes%} |
||||
<p>{{c|striptags|safe}}</p> |
<p>{{c|striptags}}</p> |
||||
{% endfor %} |
{% endfor %} |
||||
|
|||||
@ -1,5 +1,6 @@ |
|||||
|
{% load common_tags %} |
||||
<h2 class="gray-title">Expedientes</h2> |
<h2 class="gray-title">Expedientes</h2> |
||||
{% for expediente in lst_expedientes%} |
{% for expediente in lst_expedientes%} |
||||
<h3>{{expediente.nom_expediente}}</h3> |
<h3>{{expediente.nom_expediente}}</h3> |
||||
<div style="margin-bottom: 1cm">{{expediente.txt_expediente|safe}}</div> |
<div style="margin-bottom: 1cm">{{expediente.txt_expediente|sanitize}}</div> |
||||
{% endfor%} |
{% endfor%} |
||||
|
|||||
@ -1,4 +1,4 @@ |
|||||
<h2 class="gray-title">Ocorrências da Sessão</h2> |
<h2 class="gray-title">Ocorrências da Sessão</h2> |
||||
{% for o in lst_ocorrencias%} |
{% for o in lst_ocorrencias%} |
||||
<p>{{o.conteudo|striptags|safe}}</p> |
<p>{{o.conteudo|striptags}}</p> |
||||
{% endfor %} |
{% endfor %} |
||||
|
|||||
@ -1,8 +1,9 @@ |
|||||
|
{% load common_tags %} |
||||
{% if object.consideracoesfinais.conteudo %} |
{% if object.consideracoesfinais.conteudo %} |
||||
<fieldset> |
<fieldset> |
||||
<legend>Considerações Finais</legend> |
<legend>Considerações Finais</legend> |
||||
<div style="border:0.5px solid #BAB4B1; border-radius: 10px; background-color: rgba(225, 225, 225, .8);"> |
<div style="border:0.5px solid #BAB4B1; border-radius: 10px; background-color: rgba(225, 225, 225, .8);"> |
||||
<p>{{object.consideracoesfinais.conteudo|safe}}</p> |
<p>{{object.consideracoesfinais.conteudo|sanitize}}</p> |
||||
</div> |
</div> |
||||
</fieldset> |
</fieldset> |
||||
<br /><br /><br /> |
<br /><br /><br /> |
||||
|
|||||
@ -1,8 +1,9 @@ |
|||||
|
{% load common_tags %} |
||||
{% if object.ocorrenciasessao.conteudo %} |
{% if object.ocorrenciasessao.conteudo %} |
||||
<fieldset> |
<fieldset> |
||||
<legend>Ocorrências da Sessão</legend> |
<legend>Ocorrências da Sessão</legend> |
||||
<div style="border:0.5px solid #BAB4B1; border-radius: 10px; background-color: rgba(225, 225, 225, .8);"> |
<div style="border:0.5px solid #BAB4B1; border-radius: 10px; background-color: rgba(225, 225, 225, .8);"> |
||||
<p>{{object.ocorrenciasessao.conteudo|safe}}</p> |
<p>{{object.ocorrenciasessao.conteudo|sanitize}}</p> |
||||
</div> |
</div> |
||||
</fieldset> |
</fieldset> |
||||
<br /><br /><br /> |
<br /><br /><br /> |
||||
|
|||||
Loading…
Reference in new issue