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
',
+ '',
+ '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(
+ '', 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