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> |
|||
{% for c in lst_consideracoes%} |
|||
<p>{{c|striptags|safe}}</p> |
|||
<p>{{c|striptags}}</p> |
|||
{% endfor %} |
|||
|
|||
@ -1,5 +1,6 @@ |
|||
{% load common_tags %} |
|||
<h2 class="gray-title">Expedientes</h2> |
|||
{% for expediente in lst_expedientes%} |
|||
<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%} |
|||
|
|||
@ -1,4 +1,4 @@ |
|||
<h2 class="gray-title">Ocorrências da Sessão</h2> |
|||
{% for o in lst_ocorrencias%} |
|||
<p>{{o.conteudo|striptags|safe}}</p> |
|||
<p>{{o.conteudo|striptags}}</p> |
|||
{% endfor %} |
|||
|
|||
@ -1,8 +1,9 @@ |
|||
{% load common_tags %} |
|||
{% if object.consideracoesfinais.conteudo %} |
|||
<fieldset> |
|||
<legend>Considerações Finais</legend> |
|||
<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> |
|||
</fieldset> |
|||
<br /><br /><br /> |
|||
|
|||
@ -1,8 +1,9 @@ |
|||
{% load common_tags %} |
|||
{% if object.ocorrenciasessao.conteudo %} |
|||
<fieldset> |
|||
<legend>Ocorrências da Sessão</legend> |
|||
<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> |
|||
</fieldset> |
|||
<br /><br /><br /> |
|||
|
|||
Loading…
Reference in new issue