diff --git a/sapl/base/receivers.py b/sapl/base/receivers.py index a783b106b..1d0b8c892 100644 --- a/sapl/base/receivers.py +++ b/sapl/base/receivers.py @@ -504,6 +504,9 @@ def sanitize_textfields_pre_save_signal(sender, instance, **kwargs): 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 diff --git a/sapl/base/tests/test_sanitize.py b/sapl/base/tests/test_sanitize.py index cfa2766e3..c6e0534c0 100644 --- a/sapl/base/tests/test_sanitize.py +++ b/sapl/base/tests/test_sanitize.py @@ -1,10 +1,16 @@ 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 sanitize_field, sanitize_html, sanitize_scope +from sapl.sanitize import (html_fragment_is_balanced, sanitize_field, + sanitize_html, sanitize_scope) from sapl.sessao.models import ExpedienteSessao @@ -16,9 +22,10 @@ def test_plain_remove_marcacao_e_preserva_texto(): assert sanitize_html('
a
b
') == 'ab' -def test_plain_escapa_caracteres_especiais(): - assert sanitize_html('Valor < 10 & prazo > 5') == \ - 'Valor < 10 & prazo > 5' +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(): @@ -39,11 +46,7 @@ def test_valores_vazios_atravessam(): '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. - """ + """Salvar de novo um registro já sanitizado não altera o texto.""" uma_vez = sanitize_html(valor) assert sanitize_html(uma_vez) == uma_vez @@ -108,6 +111,8 @@ 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(): @@ -161,3 +166,63 @@ def test_get_field_display_protege_linha_legada(): __, 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 9afbe3201..4bb1015f1 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 3a328e5b5..711f9aaa0 100644 --- a/sapl/crispy_layout_mixin.py +++ b/sapl/crispy_layout_mixin.py @@ -8,10 +8,11 @@ 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_field +from sapl.sanitize import sanitize_html, sanitize_scope def heads_and_tails(list_of_lists): @@ -169,7 +170,13 @@ def get_field_display(obj, fieldname): args=(value.id,)), value) elif 'TextField' in str_type_from_field: - display = sanitize_field(obj._meta.model, fieldname, value) + 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: diff --git a/sapl/sanitize.py b/sapl/sanitize.py index a505e0a8f..fcbe9db02 100644 --- a/sapl/sanitize.py +++ b/sapl/sanitize.py @@ -6,16 +6,22 @@ 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 +* ``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). -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. +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 = { @@ -54,6 +60,10 @@ SANITIZE_CLEAN_CONTENT_TAGS = {'script', 'style'} # compilacao.TipoDispositivo são fragmentos de template configurados por # administradores. RICH_TEXT_FIELDS = { + 'base.CasaLegislativa': {'informacao_geral'}, + 'norma.NormaRelacionada': {'resumo'}, + 'parlamentares.Legislatura': {'observacao'}, + 'parlamentares.Parlamentar': {'biografia'}, 'sessao.ExpedienteSessao': {'conteudo'}, 'sessao.OcorrenciaSessao': {'conteudo'}, 'sessao.ConsideracoesFinais': {'conteudo'}, @@ -65,12 +75,12 @@ RICH_TEXT_FIELDS = { }, } -# Modelos cujos TextField não devem ser tocados em hipótese alguma. -SANITIZE_EXEMPT_MODELS = { +# Campos que não devem ser tocados em hipótese alguma. +SANITIZE_EXEMPT_FIELDS = { # xml é XML fornecido pela equipe do LexML; já é escapado em pretty_xml - 'lexml.LexmlProvedor', + 'lexml.LexmlProvedor': {'xml'}, # rodape_global é interpolado dentro de um content: de CSS - 'compilacao.TipoTextoArticulado', + 'compilacao.TipoTextoArticulado': {'rodape_global'}, } @@ -81,7 +91,7 @@ def model_key(model): 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: + if fieldname in SANITIZE_EXEMPT_FIELDS.get(key, ()): return 'exempt' if fieldname in RICH_TEXT_FIELDS.get(key, ()): return 'rich' @@ -91,7 +101,8 @@ def sanitize_scope(model, fieldname): 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=False`` toda a marcação é removida e apenas o texto sobra, + sem escape: ``&`` continua ``&``. 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. @@ -112,21 +123,60 @@ def sanitize_html(value, rich=False): link_rel='noopener noreferrer', strip_comments=True) - return nh3.clean( + return html.unescape(nh3.clean( value, tags=set(), attributes={}, clean_content_tags=SANITIZE_CLEAN_CONTENT_TAGS, link_rel=None, - strip_comments=True) + 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. + Campos isentos atravessam sem modificação. """ scope = sanitize_scope(model, fieldname) if scope == 'exempt': return value return sanitize_html(value, rich=(scope == 'rich')) + + +_VOID_TAGS = { + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', + 'meta', 'param', 'source', 'track', 'wbr', +} + + +class _BalanceChecker(HTMLParser): + + def __init__(self): + super().__init__(convert_charrefs=True) + self.stack = [] + self.balanced = True + + def handle_starttag(self, tag, attrs): + if tag not in _VOID_TAGS: + self.stack.append(tag) + + def handle_startendtag(self, tag, attrs): + pass + + def handle_endtag(self, tag): + if tag in _VOID_TAGS: + return + if not self.stack or self.stack.pop() != tag: + self.balanced = False + + +def html_fragment_is_balanced(value): + """Indica se toda tag aberta em ``value`` é fechada nele mesmo. + + Fragmentos desbalanceados (```` num campo, ```` em outro) + são reescritos pela sanitização ``rich``, que fecha ou descarta as tags. + """ + checker = _BalanceChecker() + checker.feed(value) + checker.close() + return checker.balanced and not checker.stack diff --git a/sapl/templates/compilacao/dispositivo_form_search_fragment_child.html b/sapl/templates/compilacao/dispositivo_form_search_fragment_child.html index d835a87bc..6ce293c99 100644 --- a/sapl/templates/compilacao/dispositivo_form_search_fragment_child.html +++ b/sapl/templates/compilacao/dispositivo_form_search_fragment_child.html @@ -1,4 +1,4 @@ -{% load i18n compilacao_filters %} +{% load i18n compilacao_filters common_tags %}
  • @@ -30,7 +30,7 @@ {{ df.tipo_dispositivo.rotulo_prefixo_html|safe }} {% if df.rotulo or df.nivel == 1%}{{ df.rotulo }}{%else%}[{{ df|nomenclatura}} {% trans "de" %} {{ df.dispositivo_pai.rotulo }}] - {% endif %} {{ df.tipo_dispositivo.rotulo_sufixo_html|safe }} - {{ df.tipo_dispositivo.texto_prefixo_html|safe }}{%if df.texto %}{{ df.texto|safe }}{%else%}{%if not df.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %} + {{ df.tipo_dispositivo.texto_prefixo_html|safe }}{%if df.texto %}{{ df.texto|sanitize }}{%else%}{%if not df.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %} {% if df.ta_publicado_id %} {{ df.tipo_dispositivo.nota_automatica_prefixo_html|safe }} diff --git a/sapl/templates/compilacao/layout/dispositivo_checkbox.html b/sapl/templates/compilacao/layout/dispositivo_checkbox.html index e0a7ab4d5..833b18225 100644 --- a/sapl/templates/compilacao/layout/dispositivo_checkbox.html +++ b/sapl/templates/compilacao/layout/dispositivo_checkbox.html @@ -37,7 +37,7 @@ {{ choice.1.tipo_dispositivo.rotulo_prefixo_html|safe }} {% if choice.1.rotulo %}{{ choice.1.rotulo }}{%else%}[{{ choice.1|nomenclatura}}{% if choice.1.dispositivo_pai %} {% trans "de" %} {{ choice.1.dispositivo_pai.rotulo }}{% endif %}] - {% endif %} {{ choice.1.tipo_dispositivo.rotulo_sufixo_html|safe }} - {{ choice.1.tipo_dispositivo.texto_prefixo_html|safe }}{%if choice.1.texto %}{{ choice.1.texto|safe }}{%else%}{%if not choice.1.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %} + {{ choice.1.tipo_dispositivo.texto_prefixo_html|safe }}{%if choice.1.texto %}{{ choice.1.texto|sanitize }}{%else%}{%if not choice.1.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %} {% if choice.1.ta_publicado_id and not choice.1.tipo_dispositivo.dispositivo_de_articulacao %} {{ choice.1.tipo_dispositivo.nota_automatica_prefixo_html|safe }} diff --git a/sapl/templates/compilacao/layout/dispositivo_radio.html b/sapl/templates/compilacao/layout/dispositivo_radio.html index f0cd84e6b..076e94bc7 100644 --- a/sapl/templates/compilacao/layout/dispositivo_radio.html +++ b/sapl/templates/compilacao/layout/dispositivo_radio.html @@ -35,7 +35,7 @@ {{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|safe }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %} + pk="{{dpt.pk}}">{{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|sanitize }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %} {% if dpt.ta_publicado_id and not dpt.tipo_dispositivo.dispositivo_de_articulacao %} {{ dpt.tipo_dispositivo.nota_automatica_prefixo_html|safe }} diff --git a/sapl/templates/compilacao/text_edit_blocoalteracao.html b/sapl/templates/compilacao/text_edit_blocoalteracao.html index d2d9f953f..b76c66dfb 100644 --- a/sapl/templates/compilacao/text_edit_blocoalteracao.html +++ b/sapl/templates/compilacao/text_edit_blocoalteracao.html @@ -5,7 +5,7 @@
    - {{ ch.tipo_dispositivo.rotulo_prefixo_html|safe }}{{ ch.rotulo }}{{ ch.tipo_dispositivo.rotulo_sufixo_html|safe }}{{ ch.tipo_dispositivo.texto_prefixo_html|safe }}{{ ch.texto|safe }} + {{ ch.tipo_dispositivo.rotulo_prefixo_html|safe }}{{ ch.rotulo }}{{ ch.tipo_dispositivo.rotulo_sufixo_html|safe }}{{ ch.tipo_dispositivo.texto_prefixo_html|safe }}{{ ch.texto|sanitize }}
    diff --git a/sapl/templates/compilacao/text_notificacoes.html b/sapl/templates/compilacao/text_notificacoes.html index f30709710..ada42862d 100644 --- a/sapl/templates/compilacao/text_notificacoes.html +++ b/sapl/templates/compilacao/text_notificacoes.html @@ -23,7 +23,7 @@ {{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|safe }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %} + pk="{{dpt.pk}}">{{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|sanitize }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %} {%if dpt.ta_publicado_id %} diff --git a/sapl/templates/parlamentares/parlamentar_perfil_publico.html b/sapl/templates/parlamentares/parlamentar_perfil_publico.html index c5c4a0439..872a38747 100644 --- a/sapl/templates/parlamentares/parlamentar_perfil_publico.html +++ b/sapl/templates/parlamentares/parlamentar_perfil_publico.html @@ -61,7 +61,7 @@
    -

    Biografia:   {{object.biografia|linebreaksbr}}

    +

    Biografia:   {{object.biografia|sanitize}}

    diff --git a/sapl/templates/relatorios/blocos_sessao_plenaria/consideracoes_finais.html b/sapl/templates/relatorios/blocos_sessao_plenaria/consideracoes_finais.html index 9d4211d5d..d499f5594 100644 --- a/sapl/templates/relatorios/blocos_sessao_plenaria/consideracoes_finais.html +++ b/sapl/templates/relatorios/blocos_sessao_plenaria/consideracoes_finais.html @@ -1,4 +1,5 @@ +{% load common_tags %}

    Considerações Finais

    {% for c in lst_consideracoes%} -

    {{c|striptags}}

    +

    {{c|sanitize|striptags}}

    {% endfor %} diff --git a/sapl/templates/relatorios/blocos_sessao_plenaria/ocorrencias_da_sessao.html b/sapl/templates/relatorios/blocos_sessao_plenaria/ocorrencias_da_sessao.html index c34775934..9df62eb6d 100644 --- a/sapl/templates/relatorios/blocos_sessao_plenaria/ocorrencias_da_sessao.html +++ b/sapl/templates/relatorios/blocos_sessao_plenaria/ocorrencias_da_sessao.html @@ -1,4 +1,5 @@ +{% load common_tags %}

    Ocorrências da Sessão

    {% for o in lst_ocorrencias%} -

    {{o.conteudo|striptags}}

    +

    {{o.conteudo|sanitize|striptags}}

    {% endfor %} diff --git a/sapl/templates/sessao/blocos_ata/consideracoes_finais.html b/sapl/templates/sessao/blocos_ata/consideracoes_finais.html index 967946e58..11d4e602e 100644 --- a/sapl/templates/sessao/blocos_ata/consideracoes_finais.html +++ b/sapl/templates/sessao/blocos_ata/consideracoes_finais.html @@ -1,8 +1,9 @@ +{% load common_tags %} {% if object.consideracoesfinais.conteudo %}

    Considerações Finais: - {{object.consideracoesfinais.conteudo|striptags}} + {{object.consideracoesfinais.conteudo|sanitize|striptags}}

    {% endif %} diff --git a/sapl/templates/sessao/blocos_ata/expedientes.html b/sapl/templates/sessao/blocos_ata/expedientes.html index 52ba79245..8f5ad9231 100644 --- a/sapl/templates/sessao/blocos_ata/expedientes.html +++ b/sapl/templates/sessao/blocos_ata/expedientes.html @@ -1,3 +1,4 @@ +{% load common_tags %} {% if expedientes %}

    @@ -5,7 +6,7 @@ {% for e in expedientes %} {% if e.conteudo %} {{ e.tipo }}: - {{ e.conteudo|striptags }} + {{ e.conteudo|sanitize|striptags }} {% endif %} {% endfor %}

    diff --git a/sapl/templates/sessao/blocos_ata/ocorrencias_da_sessao.html b/sapl/templates/sessao/blocos_ata/ocorrencias_da_sessao.html index 81ac596bf..3f6fbb0f4 100644 --- a/sapl/templates/sessao/blocos_ata/ocorrencias_da_sessao.html +++ b/sapl/templates/sessao/blocos_ata/ocorrencias_da_sessao.html @@ -1,8 +1,9 @@ +{% load common_tags %} {% if object.ocorrenciasessao.conteudo %}

    Ocorrências da Sessão: - {{ object.ocorrenciasessao.conteudo|striptags }} + {{ object.ocorrenciasessao.conteudo|sanitize|striptags }}

    {% endif %}