Browse Source

Identifica conteúdo ativo também pelo tipo MIME da extensão

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pull/3865/head
Edward Ribeiro 3 weeks ago
parent
commit
7b596f9faf
  1. 19
      sapl/base/media.py
  2. 12
      sapl/base/tests/test_media.py
  3. 18
      sapl/test_utils.py
  4. 15
      sapl/utils.py

19
sapl/base/media.py

@ -11,15 +11,15 @@ from sapl.protocoloadm.models import (DocumentoAcessorioAdministrativo,
DocumentoAdministrativo, DocumentoAdministrativo,
pode_ver_documento_adm) pode_ver_documento_adm)
from sapl.sessao.models import SessaoPlenaria, restringe_sessoes_visiveis from sapl.sessao.models import SessaoPlenaria, restringe_sessoes_visiveis
from sapl.utils import EXTENSOES_ATIVAS from sapl.utils import EXTENSOES_ATIVAS, tipo_ativo
X_ACCEL_PREFIX = '/media-interna/' X_ACCEL_PREFIX = '/media-interna/'
# Arquivos que dependem de autorização. Inclui as pastas public/ onde # Arquivos que dependem de autorização, em private/ ou em qualquer outra
# ficaram os arquivos enviados antes da criação de private/. # pasta (public/ guarda os enviados antes da criação de private/).
PROTEGIDO_RE = re.compile( PROTEGIDO_RE = re.compile(
r'^sapl/(?:private|public/(?:documentoadministrativo|' r'^sapl/[^/]+/(?:documentoadministrativo|'
r'documentoacessorioadministrativo|proposicao))/') r'documentoacessorioadministrativo|proposicao)/')
def _pode_ver_proposicao(user, proposicao): def _pode_ver_proposicao(user, proposicao):
@ -75,7 +75,8 @@ def media(request, path):
if any(p in ('', '.', '..') for p in partes) or '\x00' in path: if any(p in ('', '.', '..') for p in partes) or '\x00' in path:
raise Http404 raise Http404
protegido = bool(PROTEGIDO_RE.match(path)) protegido = path.startswith('sapl/private/') or bool(
PROTEGIDO_RE.match(path))
nome = _nome_protegido(request, path) if protegido else path nome = _nome_protegido(request, path) if protegido else path
if path.startswith('sapl/public/sessaoplenaria/') and len(partes) > 4 \ if path.startswith('sapl/public/sessaoplenaria/') and len(partes) > 4 \
@ -83,12 +84,12 @@ def media(request, path):
raise Http404 raise Http404
nome_arquivo = os.path.basename(nome) nome_arquivo = os.path.basename(nome)
if os.path.splitext(nome_arquivo)[1].lower() in EXTENSOES_ATIVAS:
content_type, disposicao = 'application/octet-stream', 'attachment'
else:
content_type = (mimetypes.guess_type(nome_arquivo)[0] or content_type = (mimetypes.guess_type(nome_arquivo)[0] or
'application/octet-stream') 'application/octet-stream')
disposicao = 'inline' disposicao = 'inline'
if os.path.splitext(nome_arquivo)[1].lower() in EXTENSOES_ATIVAS or \
tipo_ativo(content_type):
content_type, disposicao = 'application/octet-stream', 'attachment'
if settings.DEBUG: if settings.DEBUG:
try: try:

12
sapl/base/tests/test_media.py

@ -212,7 +212,8 @@ def test_arquivo_publico_passa_por_x_accel(client):
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('nome', ['x.svg', 'x.html', 'x.XML', 'x.js']) @pytest.mark.parametrize('nome', ['x.svg', 'x.html', 'x.XML', 'x.js',
'x.xht', 'x.rdf', 'x.xslt'])
def test_arquivo_publico_ativo_e_baixado(client, nome): def test_arquivo_publico_ativo_e_baixado(client, nome):
response = _get(client, 'sapl/public/normajuridica/2026/1/' + nome) response = _get(client, 'sapl/public/normajuridica/2026/1/' + nome)
@ -253,3 +254,12 @@ def test_debug_serve_arquivo(rf, settings, tmp_path):
for path in (DOC_PATH, 'sapl/public/normajuridica/1/nao.pdf'): for path in (DOC_PATH, 'sapl/public/normajuridica/1/nao.pdf'):
with pytest.raises(Http404): with pytest.raises(Http404):
chama(path) chama(path)
@pytest.mark.django_db(transaction=False)
def test_modelo_protegido_fora_de_private_e_public(client):
_config('O')
path = 'sapl/outra/documentoadministrativo/2026/1/texto.pdf'
baker.make(DocumentoAdministrativo, restrito=True, texto_integral=path)
assert _get(client, path).status_code == 404

18
sapl/test_utils.py

@ -2,7 +2,8 @@ from django.core.exceptions import ValidationError
import pytest import pytest
from .utils import (listify, rejeita_conteudo_ativo, from .utils import (listify, rejeita_conteudo_ativo,
restringe_tipos_de_arquivo_txt, validar_arquivo) restringe_tipos_de_arquivo_txt, tipo_ativo,
validar_arquivo)
def test_listify(): def test_listify():
@ -40,6 +41,8 @@ ATIVOS = [
('x.html', b'<html><script>alert(1)</script></html>'), ('x.html', b'<html><script>alert(1)</script></html>'),
('x.svg', b'<svg xmlns="http://www.w3.org/2000/svg"></svg>'), ('x.svg', b'<svg xmlns="http://www.w3.org/2000/svg"></svg>'),
('x.XHTML', b'%PDF-1.4'), ('x.XHTML', b'%PDF-1.4'),
('x.xht', b'%PDF-1.4\n<html><script>alert(1)</script></html>'),
('x.rdf', b'%PDF-1.4'),
('x.pdf', b'<!DOCTYPE html><html><script>alert(1)</script></html>'), ('x.pdf', b'<!DOCTYPE html><html><script>alert(1)</script></html>'),
] ]
@ -83,3 +86,16 @@ def test_validar_arquivo_ignora_arquivo_ja_gravado():
antigo = FieldFile(None, campo, antigo = FieldFile(None, campo,
'sapl/private/documentoadministrativo/x.html') 'sapl/private/documentoadministrativo/x.html')
rejeita_conteudo_ativo(antigo) rejeita_conteudo_ativo(antigo)
@pytest.mark.parametrize('mime,ativo', [
('text/html', True), ('application/xhtml+xml', True),
('application/rdf+xml', True), ('text/xml', True),
('application/javascript', True), ('image/svg+xml', True),
('application/pdf', False), ('image/png', False), ('text/csv', False),
('application/vnd.openxmlformats-officedocument.wordprocessingml.document',
False),
(None, False),
])
def test_tipo_ativo(mime, ativo):
assert tipo_ativo(mime) is ativo

15
sapl/utils.py

@ -41,6 +41,7 @@ from django.utils.translation import ugettext_lazy as _
import django_filters import django_filters
from easy_thumbnails import source_generators from easy_thumbnails import source_generators
import magic import magic
import mimetypes
import requests import requests
from unipath.path import Path from unipath.path import Path
from xlsxwriter.workbook import Workbook from xlsxwriter.workbook import Workbook
@ -614,18 +615,28 @@ MIMES_ATIVOS = {
} }
def tipo_ativo(mime):
"""MIME que o navegador renderiza como documento capaz de rodar script."""
if not mime:
return False
mime = mime.lower()
return (mime in MIMES_ATIVOS or 'html' in mime or 'javascript' in mime
or mime.endswith('/xml') or mime.endswith('+xml'))
def rejeita_conteudo_ativo(arquivo): def rejeita_conteudo_ativo(arquivo):
"""Recusa uploads que o navegador executaria como página, pela extensão """Recusa uploads que o navegador executaria como página, pela extensão
ou pelo conteúdo. Arquivos já gravados não são checados, para não impedir ou pelo conteúdo. Arquivos já gravados não são checados, para não impedir
a edição de registros antigos.""" a edição de registros antigos."""
if not isinstance(arquivo, UploadedFile): if not isinstance(arquivo, UploadedFile):
return return
if os.path.splitext(arquivo.name)[1].lower() in EXTENSOES_ATIVAS: if os.path.splitext(arquivo.name)[1].lower() in EXTENSOES_ATIVAS or \
tipo_ativo(mimetypes.guess_type(arquivo.name)[0]):
raise ValidationError(_('Tipo de arquivo não permitido.')) raise ValidationError(_('Tipo de arquivo não permitido.'))
arquivo.seek(0) arquivo.seek(0)
mime = magic.from_buffer(arquivo.read(2048), mime=True) mime = magic.from_buffer(arquivo.read(2048), mime=True)
arquivo.seek(0) arquivo.seek(0)
if mime in MIMES_ATIVOS: if tipo_ativo(mime):
raise ValidationError(_('Tipo de arquivo não permitido.')) raise ValidationError(_('Tipo de arquivo não permitido.'))
TIPOS_TEXTO_PERMITIDOS = ( TIPOS_TEXTO_PERMITIDOS = (

Loading…
Cancel
Save