diff --git a/requirements/requirements.txt b/requirements/requirements.txt index ca05c71b7..79a88872f 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -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 diff --git a/sapl/base/receivers.py b/sapl/base/receivers.py index f0e5365c6..1d0b8c892 100644 --- a/sapl/base/receivers.py +++ b/sapl/base/receivers.py @@ -8,6 +8,7 @@ from django.conf import settings 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 @@ -22,11 +23,15 @@ from sapl.decorators import receiver_multi_senders 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): @@ -477,6 +482,46 @@ 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. + """ + # loaddata (inclusive dentro de migrations) grava o objeto literalmente + if kwargs.get('raw'): + return + if sender._meta.app_label not in SAPL_APP_LABELS: + 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') diff --git a/sapl/base/templatetags/common_tags.py b/sapl/base/templatetags/common_tags.py index 84f61b64f..d2c741ee6 100644 --- a/sapl/base/templatetags/common_tags.py +++ b/sapl/base/templatetags/common_tags.py @@ -10,6 +10,7 @@ from sapl.base.models import AppConfig 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 @@ -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): diff --git a/sapl/base/tests/test_sanitize.py b/sapl/base/tests/test_sanitize.py new file mode 100644 index 000000000..c6e0534c0 --- /dev/null +++ b/sapl/base/tests/test_sanitize.py @@ -0,0 +1,228 @@ +import pytest +from django.db.models.signals import pre_save +from django.template import Context, Template +from model_bakery import baker + +from sapl.compilacao.forms import TipoDispositivoForm +from sapl.compilacao.models import TipoDispositivo, TipoTextoArticulado +from sapl.crispy_layout_mixin import get_field_display +from sapl.lexml.models import LexmlProvedor +from sapl.parlamentares.models import Parlamentar +from sapl.protocoloadm.models import TramitacaoAdministrativo +from sapl.sanitize import (html_fragment_is_balanced, sanitize_field, + sanitize_html, sanitize_scope) +from sapl.sessao.models import ExpedienteSessao + + +def test_plain_remove_marcacao_e_preserva_texto(): + assert sanitize_html('Ciente') == 'Ciente' + assert sanitize_html('Encaminhado ao setor') == \ + 'Encaminhado ao setor' + assert sanitize_html('') == '' + assert sanitize_html('
a
b
') == 'ab' + + +def test_plain_guarda_texto_puro_sem_escape(): + """O escape é da renderização; gravado escapado, apareceria & na tela.""" + assert sanitize_html('Valor < 10 & prazo > 5') == 'Valor < 10 & prazo > 5' + assert sanitize_html('ALFA & BETA') == 'ALFA & BETA' + + +def test_plain_preserva_quebras_de_linha(): + # get_field_display converte \n em
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', [ + 'Ciente', + 'Valor < 10 & prazo > 5', + 'Encaminhado ao setor', + 'texto & cia', +]) +def test_plain_e_idempotente(valor): + """Salvar de novo um registro já sanitizado não altera o texto.""" + uma_vez = sanitize_html(valor) + assert sanitize_html(uma_vez) == uma_vez + + +@pytest.mark.parametrize('valor', [ + 'Portal', + '

centro

', + '
c
', + 'ok', +]) +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( + 'Portal', + rich=True) + assert 'href="https://camara.gov.br"' in saida + assert 'target="_blank"' in saida + assert 'rel="noopener noreferrer"' in saida + assert '>Portal' in saida + + assert 'href="/materia/123"' in sanitize_html( + 'Matéria', rich=True) + assert 'href="mailto:a@b.c"' in sanitize_html( + 'mail', rich=True) + + +def test_rich_remove_href_perigosa_mas_mantem_o_texto(): + saida = sanitize_html( + 'clique', rich=True) + assert 'javascript' not in saida + assert 'clique' in saida + + +def test_rich_remove_script_e_manipuladores_de_evento(): + saida = sanitize_html('ok', rich=True) + assert saida == 'ok' + + assert 'onclick' not in sanitize_html( + 'x', rich=True) + assert 'onerror' not in sanitize_html( + '', 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( + '

centro

', rich=True) + + saida = sanitize_html( + '
c
', rich=True) + assert '' in saida and 'colspan="2"' in saida + + assert sanitize_html('', rich=True) == \ + '' + + +def test_sanitize_scope(): + assert sanitize_scope(TramitacaoAdministrativo, 'texto') == 'plain' + assert sanitize_scope(ExpedienteSessao, 'conteudo') == 'rich' + assert sanitize_scope(LexmlProvedor, 'xml') == 'exempt' + assert sanitize_scope(TipoTextoArticulado, 'rodape_global') == 'exempt' + assert sanitize_scope(Parlamentar, 'biografia') == 'rich' + + +def test_sanitize_field_respeita_isencao(): + xml = 'y' + assert sanitize_field(LexmlProvedor, 'xml', xml) == xml + + +@pytest.mark.django_db +def test_pre_save_sanitiza_campo_simples(): + t = baker.make(TramitacaoAdministrativo, + texto='Ciente ok') + 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='x' + 'l') + e.refresh_from_db() + assert '' + 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='Ciente') + __, display = get_field_display(t, 'texto') + assert 'legado') + t.refresh_from_db() + assert t.texto == 'legado' + + __, display = get_field_display(t, 'texto') + assert '') + __, display = get_field_display(p, 'xml') + assert ''})) + assert saida == 'Ofício 12 lido & arquivado' + + +@pytest.mark.parametrize('valor, esperado', [ + ('
', True), + ('
', True), + ('
Justificativa
', True), + ('Art. ', True), + ('', False), + ('', False), + ('x', False), +]) +def test_html_fragment_is_balanced(valor, esperado): + assert html_fragment_is_balanced(valor) is esperado + + +@pytest.mark.django_db +def test_tipo_dispositivo_form_rejeita_fragmento_desbalanceado(): + td = baker.make(TipoDispositivo) + dados = {f: getattr(td, f) or '' for f in TipoDispositivoForm.Meta.fields} + dados['rotulo_prefixo_html'] = '' + dados['rotulo_sufixo_html'] = '' + form = TipoDispositivoForm(data=dados, instance=td) + assert not form.is_valid() + assert 'rotulo_prefixo_html' in form.errors + assert 'rotulo_sufixo_html' in form.errors + + dados['rotulo_prefixo_html'] = '
' + dados['rotulo_sufixo_html'] = '' + form = TipoDispositivoForm(data=dados, instance=td) + assert 'rotulo_prefixo_html' not in form.errors diff --git a/sapl/compilacao/forms.py b/sapl/compilacao/forms.py index c4154ec00..3ff3b521d 100644 --- a/sapl/compilacao/forms.py +++ b/sapl/compilacao/forms.py @@ -26,6 +26,7 @@ from sapl.compilacao.models import (NOTAS_PUBLICIDADE_CHOICES, from sapl.compilacao.utils import DISPOSITIVO_SELECT_RELATED from sapl.crispy_layout_mixin import SaplFormHelper from sapl.crispy_layout_mixin import SaplFormLayout, to_column, to_row +from sapl.sanitize import RICH_TEXT_FIELDS, html_fragment_is_balanced from sapl.utils import YES_NO_CHOICES, FileFieldCheckMixin @@ -51,6 +52,33 @@ ta_error_messages = { } +class TipoDispositivoForm(ModelForm): + + class Meta: + model = TipoDispositivo + fields = ['rotulo_ordinal', 'formato_variacao0', + 'rotulo_separador_variacao01', 'formato_variacao1', + 'rotulo_separador_variacao12', 'formato_variacao2', + 'rotulo_separador_variacao23', 'formato_variacao3', + 'rotulo_separador_variacao34', 'formato_variacao4', + 'rotulo_separador_variacao45', 'formato_variacao5', + 'rotulo_prefixo_html', 'rotulo_sufixo_html', + 'texto_prefixo_html', 'texto_sufixo_html', + 'nota_automatica_prefixo_html', + 'nota_automatica_sufixo_html'] + + def clean(self): + cleaned_data = super().clean() + for fieldname in RICH_TEXT_FIELDS['compilacao.TipoDispositivo']: + value = cleaned_data.get(fieldname) + if value and not html_fragment_is_balanced(value): + self.add_error(fieldname, _( + 'Toda tag aberta neste campo deve ser fechada nele ' + 'mesmo; tags divididas entre prefixo e sufixo são ' + 'descartadas ao salvar.')) + return cleaned_data + + class TipoTaForm(ModelForm): sigla = forms.CharField( label=TipoTextoArticulado._meta.get_field( diff --git a/sapl/compilacao/views.py b/sapl/compilacao/views.py index 93a066b3f..995b78368 100644 --- a/sapl/compilacao/views.py +++ b/sapl/compilacao/views.py @@ -39,7 +39,8 @@ from sapl.compilacao.forms import (DispositivoDefinidorVigenciaForm, DispositivoRegistroRevogacaoForm, DispositivoSearchModalForm, NotaForm, PublicacaoForm, TaForm, - TextNotificacoesForm, TipoTaForm, VideForm) + TextNotificacoesForm, TipoDispositivoForm, + TipoTaForm, VideForm) from sapl.compilacao.models import (STATUS_TA_EDITION, STATUS_TA_PRIVATE, STATUS_TA_PUBLIC, Dispositivo, Nota, PerfilEstruturalTextoArticulado, @@ -85,6 +86,7 @@ class TipoDispositivoCrud(CrudAux): class UpdateView(CrudAux.UpdateView): layout_key = 'TipoDispositivoUpdate' + form_class = TipoDispositivoForm class ListView(CrudAux.ListView): paginate_by = 100 diff --git a/sapl/crispy_layout_mixin.py b/sapl/crispy_layout_mixin.py index 57bc0d538..711f9aaa0 100644 --- a/sapl/crispy_layout_mixin.py +++ b/sapl/crispy_layout_mixin.py @@ -8,9 +8,12 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse, reverse_lazy from django.utils import formats from django.utils.encoding import force_text +from django.utils.html import escape from django.utils.translation import ugettext as _ import yaml +from sapl.sanitize import sanitize_html, sanitize_scope + def heads_and_tails(list_of_lists): for alist in list_of_lists: @@ -167,7 +170,14 @@ def get_field_display(obj, fieldname): args=(value.id,)), value) elif 'TextField' in str_type_from_field: - display = value.replace('\n', '
') + scope = sanitize_scope(obj._meta.model, fieldname) + if scope == 'rich': + display = sanitize_html(value, rich=True) + elif scope == 'plain': + display = escape(sanitize_html(value)) + else: + display = escape(value) + display = display.replace('\n', '
') display = '
{}
'.format(display) else: display = str(value) diff --git a/sapl/sanitize.py b/sapl/sanitize.py new file mode 100644 index 000000000..fcbe9db02 --- /dev/null +++ b/sapl/sanitize.py @@ -0,0 +1,182 @@ +"""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 guarda texto puro, com as entidades + já decodificadas: o escape fica por conta da renderização. É 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). + +A ``rich`` é idempotente, o que permite aplicá-la tanto no ``pre_save`` quanto +na renderização. A ``plain`` só não é idempotente para texto que seja HTML +codificado em entidades (``<b>``): a primeira passada decodifica, a +segunda remove a tag. Na renderização, campos ``plain`` são escapados em vez +de re-sanitizados. +""" + +import html +from html.parser import HTMLParser + +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