Browse Source

Mitigate pesquisar-sessao DDoS and fix page= param pollution

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 <noreply@anthropic.com>
rate-limiter-2026
Edward Ribeiro 1 week ago
parent
commit
5086491157
  1. 1
      docker/config/nginx/sapl.conf
  2. 9
      sapl/base/views.py
  3. 9
      sapl/materia/views.py
  4. 13
      sapl/middleware/ratelimit.py
  5. 25
      sapl/middleware/test_ratelimiter.py
  6. 9
      sapl/norma/views.py
  7. 24
      sapl/parlamentares/views.py
  8. 14
      sapl/protocoloadm/views.py
  9. 14
      sapl/sessao/views.py

1
docker/config/nginx/sapl.conf

@ -158,6 +158,7 @@ server {
proxy_pass http://sapl_server; proxy_pass http://sapl_server;
} }
# ---------------------------------------------------------------- # ----------------------------------------------------------------
# Scanner extension probes (.php, .asp, etc.) — SAPL never serves # Scanner extension probes (.php, .asp, etc.) — SAPL never serves
# these. Drop the connection before reaching Gunicorn. # these. Drop the connection before reaching Gunicorn.

9
sapl/base/views.py

@ -1458,12 +1458,9 @@ class PesquisarAuditLogView(PermissionRequiredMixin, FilterView):
data = self.filterset.data data = self.filterset.data
url = '' qr = self.request.GET.copy()
qr.pop('page', None)
if data: url = ('&' + qr.urlencode()) if qr else ''
url = '&' + str(self.request.META["QUERY_STRING"])
if url.startswith("&page"):
url = ''
resultados = self.object_list resultados = self.object_list
# if 'page' in self.request.META['QUERY_STRING']: # if 'page' in self.request.META['QUERY_STRING']:

9
sapl/materia/views.py

@ -396,12 +396,9 @@ class PesquisarStatusTramitacaoView(FilterView):
data = self.filterset.data data = self.filterset.data
url = '' qr = self.request.GET.copy()
qr.pop('page', None)
if data: url = ('&' + qr.urlencode()) if qr else ''
url = '&' + str(self.request.META["QUERY_STRING"])
if url.startswith("&page"):
url = ''
if 'descricao' in self.request.META['QUERY_STRING'] or\ if 'descricao' in self.request.META['QUERY_STRING'] or\
'page' in self.request.META['QUERY_STRING']: 'page' in self.request.META['QUERY_STRING']:

13
sapl/middleware/ratelimit.py

@ -402,6 +402,19 @@ class RateLimitMiddleware:
if any(p.match(request.path) for p in self._bypass_paths): if any(p.match(request.path) for p in self._bypass_paths):
return self.get_response(request) 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/'): if request.path.startswith('/api/'):
return self._handle_api(request) return self._handle_api(request)

25
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) 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 # _is_same_origin
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

9
sapl/norma/views.py

@ -104,12 +104,9 @@ class PesquisarAssuntoNormaView(FilterView):
data = self.filterset.data data = self.filterset.data
url = '' qr = self.request.GET.copy()
qr.pop('page', None)
if data: url = ('&' + qr.urlencode()) if qr else ''
url = '&' + str(self.request.META["QUERY_STRING"])
if url.startswith("&page"):
url = ''
if 'assunto' in self.request.META['QUERY_STRING'] or\ if 'assunto' in self.request.META['QUERY_STRING'] or\
'page' in self.request.META['QUERY_STRING']: 'page' in self.request.META['QUERY_STRING']:

24
sapl/parlamentares/views.py

@ -232,11 +232,9 @@ class PesquisarParlamentarView(FilterView):
super(PesquisarParlamentarView, self).get(request) super(PesquisarParlamentarView, self).get(request)
data = self.filterset.data data = self.filterset.data
url = '' qr = self.request.GET.copy()
if data: qr.pop('page', None)
url = "&" + str(self.request.META['QUERY_STRING']) url = ('&' + qr.urlencode()) if qr else ''
if url.startswith("&page"):
url = ''
if 'nome_parlamentar' in self.request.META['QUERY_STRING'] or\ if 'nome_parlamentar' in self.request.META['QUERY_STRING'] or\
'page' in self.request.META['QUERY_STRING']: 'page' in self.request.META['QUERY_STRING']:
@ -292,11 +290,9 @@ class PesquisarColigacaoView(FilterView):
super(PesquisarColigacaoView, self).get(request) super(PesquisarColigacaoView, self).get(request)
data = self.filterset.data data = self.filterset.data
url = '' qr = self.request.GET.copy()
if data: qr.pop('page', None)
url = "&" + str(self.request.META['QUERY_STRING']) url = ('&' + qr.urlencode()) if qr else ''
if url.startswith("&page"):
url = ''
if 'nome' in self.request.META['QUERY_STRING'] or\ if 'nome' in self.request.META['QUERY_STRING'] or\
'page' in self.request.META['QUERY_STRING']: 'page' in self.request.META['QUERY_STRING']:
@ -351,11 +347,9 @@ class PesquisarPartidoView(FilterView):
super(PesquisarPartidoView, self).get(request) super(PesquisarPartidoView, self).get(request)
data = self.filterset.data data = self.filterset.data
url = '' qr = self.request.GET.copy()
if data: qr.pop('page', None)
url = "&" + str(self.request.META['QUERY_STRING']) url = ('&' + qr.urlencode()) if qr else ''
if url.startswith("&page"):
url = ''
if 'nome' in self.request.META['QUERY_STRING'] or\ if 'nome' in self.request.META['QUERY_STRING'] or\
'page' in self.request.META['QUERY_STRING']: 'page' in self.request.META['QUERY_STRING']:

14
sapl/protocoloadm/views.py

@ -570,10 +570,9 @@ class ProtocoloPesquisaView(PermissionRequiredMixin, FilterView):
# Então a ordem da URL está diferente # Então a ordem da URL está diferente
data = self.filterset.data data = self.filterset.data
if data and data.get('numero') is not None: if data and data.get('numero') is not None:
url = "&" + str(self.request.environ['QUERY_STRING']) qr = self.request.GET.copy()
if url.startswith("&page"): qr.pop('page', None)
ponto_comeco = url.find('numero=') - 1 url = ('&' + qr.urlencode()) if qr else ''
url = url[ponto_comeco:]
else: else:
url = '' url = ''
@ -1100,10 +1099,9 @@ class PesquisarDocumentoAdministrativoView(DocumentoAdministrativoMixin,
# Então a ordem da URL está diferente # Então a ordem da URL está diferente
data = self.filterset.data data = self.filterset.data
if data and data.get('tipo') is not None: if data and data.get('tipo') is not None:
url = "&" + str(self.request.environ['QUERY_STRING']) qr = self.request.GET.copy()
if url.startswith("&page"): qr.pop('page', None)
ponto_comeco = url.find('tipo=') - 1 url = ('&' + qr.urlencode()) if qr else ''
url = url[ponto_comeco:]
else: else:
url = '' url = ''
self.filterset.form.fields['o'].label = _('Ordenação') self.filterset.form.fields['o'].label = _('Ordenação')

14
sapl/sessao/views.py

@ -11,7 +11,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Max, Prefetch, Q from django.db.models import Max, Prefetch, Q
from django.http import JsonResponse 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 import reverse
from django.urls.base import reverse_lazy from django.urls.base import reverse_lazy
from django.utils import timezone 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.encoding import force_text
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.utils.translation import ugettext_lazy as _ 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.decorators.csrf import csrf_exempt
from django.views.generic import (FormView, ListView, TemplateView) from django.views.generic import (FormView, ListView, TemplateView)
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
@ -4085,6 +4086,7 @@ class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView):
rate=smart_rate, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
@method_decorator(cache_page(60 * 2), name='get')
class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView): class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView):
model = SessaoPlenaria model = SessaoPlenaria
filterset_class = SessaoPlenariaFilterSet filterset_class = SessaoPlenariaFilterSet
@ -4144,17 +4146,17 @@ class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView):
data = self.filterset.data data = self.filterset.data
if data and data.get('data_inicio__year') is not None: if data and data.get('data_inicio__year') is not None:
url = "&" + str(self.request.META['QUERY_STRING']) qr = self.request.GET.copy()
if url.startswith("&page"): qr.pop('page', None)
ponto_comeco = url.find('data_inicio__year=') - 1 context['filter_url'] = ('&' + qr.urlencode()) if qr else ''
url = url[ponto_comeco:]
context['filter_url'] = url
context['numero_res'] = len(self.object_list) context['numero_res'] = len(self.object_list)
return context return context
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if len(request.GET.getlist('page')) > 1:
return HttpResponseBadRequest()
r = super().get(request) r = super().get(request)

Loading…
Cancel
Save