From 5086491157a70c97e6cff6e119e86e64d1ac592b Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Tue, 9 Jun 2026 12:47:19 -0300 Subject: [PATCH] Mitigate pesquisar-sessao DDoS and fix page= param pollution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coordinated DDoS attack used repeated page= params (?page=3&page=2&page=3&...) as a scraping fingerprint, likely harvested from SAPL's own paginated search links which had a long-standing bug producing those polluted URLs. Reject duplicate page= at the middleware layer: RateLimitMiddleware.__call__ returns 400 (param_pollution) if request.GET.getlist('page') has more than one value — before any Redis or DB work runs, covering all paths universally. PesquisarSessaoPlenariaView.get has the same check as a backstop. Fix the root cause — page= leaking into filter_url on 9 search views: All affected views built filter_url from the raw QUERY_STRING and guarded with startswith("&page"), which only strips page= when it is the first param. With ?filter=X&page=2 the page= leaked through and paginacao.html produced ?page=N&filter=X&page=2 on every link. Replaced with qr = request.GET.copy(); qr.pop('page', None). Views fixed: PesquisarStatusTramitacaoView, PesquisarAssuntoNormaView, PesquisarAuditLogView, PesquisarParlamentarView, PesquisarColigacaoView, PesquisarPartidoView, ProtocoloPesquisaView, PesquisarDocumentoAdministrativoView, PesquisarSessaoPlenariaView. Cache anonymous GET on PesquisarSessaoPlenariaView (2 min TTL) to reduce ORM load from repeated identical queries. Co-Authored-By: Claude Sonnet 4.6 --- docker/config/nginx/sapl.conf | 1 + sapl/base/views.py | 9 +++------ sapl/materia/views.py | 9 +++------ sapl/middleware/ratelimit.py | 13 +++++++++++++ sapl/middleware/test_ratelimiter.py | 25 +++++++++++++++++++++++++ sapl/norma/views.py | 9 +++------ sapl/parlamentares/views.py | 24 +++++++++--------------- sapl/protocoloadm/views.py | 14 ++++++-------- sapl/sessao/views.py | 14 ++++++++------ 9 files changed, 71 insertions(+), 47 deletions(-) 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)