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 %}