diff --git a/docker/config/nginx/sapl.conf b/docker/config/nginx/sapl.conf index 811ba7bbf..ea9e955da 100644 --- a/docker/config/nginx/sapl.conf +++ b/docker/config/nginx/sapl.conf @@ -158,6 +158,7 @@ server { proxy_pass http://sapl_server; } + # ---------------------------------------------------------------- # Scanner extension probes (.php, .asp, etc.) — SAPL never serves # these. Drop the connection before reaching Gunicorn. diff --git a/sapl/base/views.py b/sapl/base/views.py index a37df01cf..c85106322 100644 --- a/sapl/base/views.py +++ b/sapl/base/views.py @@ -1458,12 +1458,9 @@ class PesquisarAuditLogView(PermissionRequiredMixin, FilterView): data = self.filterset.data - url = '' - - if data: - url = '&' + str(self.request.META["QUERY_STRING"]) - if url.startswith("&page"): - url = '' + qr = self.request.GET.copy() + qr.pop('page', None) + url = ('&' + qr.urlencode()) if qr else '' resultados = self.object_list # if 'page' in self.request.META['QUERY_STRING']: diff --git a/sapl/materia/views.py b/sapl/materia/views.py index b6ef2d86c..4bf4cd62f 100644 --- a/sapl/materia/views.py +++ b/sapl/materia/views.py @@ -396,12 +396,9 @@ class PesquisarStatusTramitacaoView(FilterView): data = self.filterset.data - url = '' - - if data: - url = '&' + str(self.request.META["QUERY_STRING"]) - if url.startswith("&page"): - url = '' + qr = self.request.GET.copy() + qr.pop('page', None) + url = ('&' + qr.urlencode()) if qr else '' if 'descricao' in self.request.META['QUERY_STRING'] or\ 'page' in self.request.META['QUERY_STRING']: diff --git a/sapl/middleware/ratelimit.py b/sapl/middleware/ratelimit.py index 0d6885231..daa156dae 100644 --- a/sapl/middleware/ratelimit.py +++ b/sapl/middleware/ratelimit.py @@ -402,6 +402,19 @@ class RateLimitMiddleware: if any(p.match(request.path) for p in self._bypass_paths): return self.get_response(request) + # Reject parameter pollution: the `page` param must appear at most once. + # Duplicate page= values (e.g. ?page=3&page=2&page=3) are a bot + # fingerprint — no legitimate browser or script sends this. + if len(request.GET.getlist('page')) > 1: + logger.warning( + 'ratelimit_block layer=django reason=param_pollution ip=%s path=%s', + get_client_ip(request), request.path, + ) + self._inc_block_metric('param_pollution') + response = HttpResponse(status=400) + response['X-RateLimit-Reason'] = 'param_pollution' + return response + if request.path.startswith('/api/'): return self._handle_api(request) diff --git a/sapl/middleware/test_ratelimiter.py b/sapl/middleware/test_ratelimiter.py index e0898f7ec..cf5815520 100644 --- a/sapl/middleware/test_ratelimiter.py +++ b/sapl/middleware/test_ratelimiter.py @@ -549,6 +549,31 @@ def test_call_pass_forwards_request_to_get_response(): mw.get_response.assert_called_once_with(request) +def test_call_rejects_duplicate_page_param_with_400(): + mw, _ = _make_middleware() + request = _factory.get('/sessao/pesquisar-sessao', data={'page': ['3', '2', '3']}) + response = mw(request) + assert response.status_code == 400 + assert response['X-RateLimit-Reason'] == 'param_pollution' + mw.get_response.assert_not_called() + + +def test_call_allows_single_page_param(): + mw, _ = _make_middleware() + mw._evaluate = MagicMock(return_value={'action': 'pass', 'ip': '1.2.3.4'}) + request = _factory.get('/sessao/pesquisar-sessao', data={'page': '2'}) + mw(request) + mw.get_response.assert_called_once_with(request) + + +def test_call_allows_no_page_param(): + mw, _ = _make_middleware() + mw._evaluate = MagicMock(return_value={'action': 'pass', 'ip': '1.2.3.4'}) + request = _factory.get('/sessao/pesquisar-sessao') + mw(request) + mw.get_response.assert_called_once_with(request) + + # --------------------------------------------------------------------------- # _is_same_origin # --------------------------------------------------------------------------- diff --git a/sapl/norma/views.py b/sapl/norma/views.py index 0bef4a494..6d05d4d93 100644 --- a/sapl/norma/views.py +++ b/sapl/norma/views.py @@ -104,12 +104,9 @@ class PesquisarAssuntoNormaView(FilterView): data = self.filterset.data - url = '' - - if data: - url = '&' + str(self.request.META["QUERY_STRING"]) - if url.startswith("&page"): - url = '' + qr = self.request.GET.copy() + qr.pop('page', None) + url = ('&' + qr.urlencode()) if qr else '' if 'assunto' in self.request.META['QUERY_STRING'] or\ 'page' in self.request.META['QUERY_STRING']: diff --git a/sapl/parlamentares/views.py b/sapl/parlamentares/views.py index 10fc335e2..3275bb245 100644 --- a/sapl/parlamentares/views.py +++ b/sapl/parlamentares/views.py @@ -232,11 +232,9 @@ class PesquisarParlamentarView(FilterView): super(PesquisarParlamentarView, self).get(request) data = self.filterset.data - url = '' - if data: - url = "&" + str(self.request.META['QUERY_STRING']) - if url.startswith("&page"): - url = '' + qr = self.request.GET.copy() + qr.pop('page', None) + url = ('&' + qr.urlencode()) if qr else '' if 'nome_parlamentar' in self.request.META['QUERY_STRING'] or\ 'page' in self.request.META['QUERY_STRING']: @@ -292,11 +290,9 @@ class PesquisarColigacaoView(FilterView): super(PesquisarColigacaoView, self).get(request) data = self.filterset.data - url = '' - if data: - url = "&" + str(self.request.META['QUERY_STRING']) - if url.startswith("&page"): - url = '' + qr = self.request.GET.copy() + qr.pop('page', None) + url = ('&' + qr.urlencode()) if qr else '' if 'nome' in self.request.META['QUERY_STRING'] or\ 'page' in self.request.META['QUERY_STRING']: @@ -351,11 +347,9 @@ class PesquisarPartidoView(FilterView): super(PesquisarPartidoView, self).get(request) data = self.filterset.data - url = '' - if data: - url = "&" + str(self.request.META['QUERY_STRING']) - if url.startswith("&page"): - url = '' + qr = self.request.GET.copy() + qr.pop('page', None) + url = ('&' + qr.urlencode()) if qr else '' if 'nome' in self.request.META['QUERY_STRING'] or\ 'page' in self.request.META['QUERY_STRING']: diff --git a/sapl/protocoloadm/views.py b/sapl/protocoloadm/views.py index 226fcabea..0cd724efb 100755 --- a/sapl/protocoloadm/views.py +++ b/sapl/protocoloadm/views.py @@ -570,10 +570,9 @@ class ProtocoloPesquisaView(PermissionRequiredMixin, FilterView): # Então a ordem da URL está diferente data = self.filterset.data if data and data.get('numero') is not None: - url = "&" + str(self.request.environ['QUERY_STRING']) - if url.startswith("&page"): - ponto_comeco = url.find('numero=') - 1 - url = url[ponto_comeco:] + qr = self.request.GET.copy() + qr.pop('page', None) + url = ('&' + qr.urlencode()) if qr else '' else: url = '' @@ -1100,10 +1099,9 @@ class PesquisarDocumentoAdministrativoView(DocumentoAdministrativoMixin, # Então a ordem da URL está diferente data = self.filterset.data if data and data.get('tipo') is not None: - url = "&" + str(self.request.environ['QUERY_STRING']) - if url.startswith("&page"): - ponto_comeco = url.find('tipo=') - 1 - url = url[ponto_comeco:] + qr = self.request.GET.copy() + qr.pop('page', None) + url = ('&' + qr.urlencode()) if qr else '' else: url = '' self.filterset.form.fields['o'].label = _('Ordenação') diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py index 5ca38cab1..356359ace 100755 --- a/sapl/sessao/views.py +++ b/sapl/sessao/views.py @@ -11,7 +11,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import ObjectDoesNotExist from django.db.models import Max, Prefetch, Q from django.http import JsonResponse -from django.http.response import Http404, HttpResponseRedirect +from django.http.response import Http404, HttpResponseBadRequest, HttpResponseRedirect from django.urls import reverse from django.urls.base import reverse_lazy from django.utils import timezone @@ -20,6 +20,7 @@ from django.utils.decorators import method_decorator from django.utils.encoding import force_text from django.utils.html import strip_tags from django.utils.translation import ugettext_lazy as _ +from django.views.decorators.cache import cache_page from django.views.decorators.csrf import csrf_exempt from django.views.generic import (FormView, ListView, TemplateView) from django.views.generic.base import RedirectView @@ -4085,6 +4086,7 @@ class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView): rate=smart_rate, block=True), name='dispatch') +@method_decorator(cache_page(60 * 2), name='get') class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView): model = SessaoPlenaria filterset_class = SessaoPlenariaFilterSet @@ -4144,17 +4146,17 @@ class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView): data = self.filterset.data if data and data.get('data_inicio__year') is not None: - url = "&" + str(self.request.META['QUERY_STRING']) - if url.startswith("&page"): - ponto_comeco = url.find('data_inicio__year=') - 1 - url = url[ponto_comeco:] - context['filter_url'] = url + qr = self.request.GET.copy() + qr.pop('page', None) + context['filter_url'] = ('&' + qr.urlencode()) if qr else '' context['numero_res'] = len(self.object_list) return context def get(self, request, *args, **kwargs): + if len(request.GET.getlist('page')) > 1: + return HttpResponseBadRequest() r = super().get(request)