Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ django-prometheus==2.2.0

asn1crypto==1.5.1
XlsxWriter==3.2.0
nh3==0.2.22

setuptools==80.9.0

Expand Down
42 changes: 42 additions & 0 deletions sapl/base/receivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.contrib.contenttypes.models import ContentType
from django.core import serializers
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
from django.db.models import TextField
from django.db.models.fields.files import FileField
from django.db.models.signals import post_delete, post_save, \
post_migrate, pre_save, pre_migrate
Expand All @@ -22,11 +23,15 @@
from sapl.materia.models import Tramitacao
from sapl.parlamentares.models import Parlamentar
from sapl.protocoloadm.models import TramitacaoAdministrativo
from sapl.sanitize import sanitize_field
from sapl.utils import get_base_url, models_with_gr_for_model


models_with_gr_for_autor = models_with_gr_for_model(Autor)

SAPL_APP_LABELS = frozenset(
app.rsplit('.', 1)[-1] for app in settings.SAPL_APPS)


@receiver_multi_senders(post_save, senders=models_with_gr_for_autor)
def handle_update_autor_related(sender, **kwargs):
Expand Down Expand Up @@ -477,6 +482,43 @@ def signed_files_extraction_pre_save_signal(sender, instance, **kwargs):
signed_files_extraction_function(sender, instance, **kwargs)


# Cache dos TextField por modelo: a introspecção de _meta.fields a cada save
# pesa mais que a própria sanitização, e compilacao salva Dispositivo em laço.
_text_fields_cache = {}


def get_text_fields(model):
try:
return _text_fields_cache[model]
except KeyError:
fields = [f.name for f in model._meta.fields
if isinstance(f, TextField)]
_text_fields_cache[model] = fields
return fields


@receiver(pre_save, dispatch_uid='sanitize_textfields_pre_save_signal')
def sanitize_textfields_pre_save_signal(sender, instance, **kwargs):
"""Remove HTML/JavaScript perigoso de todo TextField dos modelos do SAPL.

Cobre forms, a API do drfautoapi, o admin e o shell num único ponto.
Ver sapl.sanitize para as políticas por campo.
"""
if sender._meta.app_label not in SAPL_APP_LABELS:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O receiver não ignora raw=True. Por convenção, receivers de pre_save pulam quando kwargs.get('raw'), porque aí o objeto vem de loaddata e deve ser gravado literalmente.

Como o sinal fica conectado durante o migrate (decisão deliberada, pelo comentário logo abaixo), as fixtures carregadas dentro de migrations acabam reescritas — por exemplo materia/migrations/0013_adiciona_status_tramitacao.py, parlamentares/migrations/0044_adiciona_cargos_mesa.py e norma/migrations/0016_*.

Na prática essas fixtures são descrições simples, então o impacto hoje é pequeno; mas é barato blindar:

if kwargs.get('raw'):
    return

return

for fieldname in get_text_fields(sender):
value = getattr(instance, fieldname, None)
if not value:
continue
sanitized = sanitize_field(sender, fieldname, value)
if sanitized != value:
setattr(instance, fieldname, sanitized)


@receiver(pre_migrate, dispatch_uid='disconnect_signals_pre_migrate')
def disconnect_signals_pre_migrate(*args, **kwargs):
# sanitize_textfields_pre_save_signal não é desconectado aqui de propósito:
# é barato e idempotente, e desconectá-lo deixaria sem proteção qualquer
# migrate rodado no mesmo processo (é o caso da suíte de testes).
pre_save.disconnect(dispatch_uid='signed_files_extraction_pre_save_signal')
12 changes: 12 additions & 0 deletions sapl/base/templatetags/common_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from sapl.materia.models import DocumentoAcessorio, MateriaLegislativa, Proposicao
from sapl.norma.models import NormaJuridica
from sapl.parlamentares.models import Filiacao
from sapl.sanitize import sanitize_html
from sapl.sessao.models import SessaoPlenaria
from sapl.utils import filiacao_data, SEPARADOR_HASH_PROPOSICAO, is_report_allowed

Expand Down Expand Up @@ -396,6 +397,17 @@ def render_chunk_vendors(extension=None):
return ''


@register.filter(is_safe=True)
@stringfilter
def sanitize(value):
"""Renderiza HTML de campo rico (TinyMCE) sem script nem URL perigosa.

Substitui o |safe nos campos que legitimamente guardam HTML. Protege
também as linhas gravadas antes da sanitização no pre_save.
"""
return mark_safe(sanitize_html(value, rich=True))


@register.filter(is_safe=True)
@stringfilter
def dont_break_out(value):
Expand Down
163 changes: 163 additions & 0 deletions sapl/base/tests/test_sanitize.py
Original file line number Diff line number Diff line change
@@ -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 &lt; 10 &amp; prazo &gt; 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 &amp; 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 &amp; 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
5 changes: 4 additions & 1 deletion sapl/crispy_layout_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from django.utils.translation import ugettext as _
import yaml

from sapl.sanitize import sanitize_field


def heads_and_tails(list_of_lists):
for alist in list_of_lists:
Expand Down Expand Up @@ -167,7 +169,8 @@ def get_field_display(obj, fieldname):
args=(value.id,)),
value)
elif 'TextField' in str_type_from_field:
display = value.replace('\n', '<br/>')
display = sanitize_field(obj._meta.model, fieldname, value)
display = display.replace('\n', '<br/>')
display = '<div class="dont-break-out">{}</div>'.format(display)
else:
display = str(value)
Expand Down
Loading