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;
}
# ----------------------------------------------------------------
# Scanner extension probes (.php, .asp, etc.) — SAPL never serves
# these. Drop the connection before reaching Gunicorn.

9
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']:

9
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']:

13
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)

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

9
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']:

24
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']:

14
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')

14
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)

Loading…
Cancel
Save