Rate limit public registration writes
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

This commit is contained in:
tegwick 2026-08-10 17:52:53 +02:00
parent cccca324c1
commit b80de5a1f4
6 changed files with 129 additions and 1 deletions

View file

@ -102,6 +102,12 @@ def create_application() -> PortalApplication:
).split(",")
if item.strip()
),
registration_rate_limit=int(
os.environ.get("USER_ENGINE_REGISTRATION_RATE_LIMIT", "10")
),
registration_rate_window_seconds=int(
os.environ.get("USER_ENGINE_REGISTRATION_RATE_WINDOW_SECONDS", "60")
),
)

View file

@ -17,6 +17,9 @@ import hmac
import json
import re
import secrets
from collections import deque
from threading import Lock
from time import monotonic
from typing import Any, Callable, Iterable, Mapping
from urllib.parse import parse_qs, urlencode, urlsplit
@ -75,6 +78,8 @@ class PortalApplication:
registration_tenants: tuple[str, ...] = (),
registration_oidc_issuer: str = "",
registration_password_setup_origins: tuple[str, ...] = (),
registration_rate_limit: int = 10,
registration_rate_window_seconds: int = 60,
) -> None:
if len(trusted_proxy_secret) < 24:
raise ValueError("trusted proxy secret must contain at least 24 characters")
@ -93,6 +98,12 @@ class PortalApplication:
self.registration_password_setup_origins = frozenset(
origin.rstrip("/") for origin in registration_password_setup_origins
)
if registration_rate_limit < 1 or registration_rate_window_seconds < 1:
raise ValueError("registration rate limit and window must be positive")
self.registration_rate_limit = registration_rate_limit
self.registration_rate_window_seconds = registration_rate_window_seconds
self._registration_attempts: dict[str, deque[float]] = {}
self._registration_attempts_lock = Lock()
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}"
@ -120,6 +131,16 @@ class PortalApplication:
def _dispatch(self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str) -> Iterable[bytes]:
method = str(environ.get("REQUEST_METHOD", "GET")).upper()
path = str(environ.get("PATH_INFO", "/")).rstrip("/") or "/"
if method == "POST" and path in {
"/register", "/registration/verify", "/registration/resume",
"/api/v1/public/registrations",
"/api/v1/public/registrations/verify",
"/api/v1/public/registrations/resume",
} and not self._accept_registration_attempt(environ):
return self._error(
start_response, "429 Too Many Requests", "rate_limited",
"Too many registration attempts. Try again later.", correlation_id,
)
if path == "/healthz":
return self._json(start_response, "200 OK", _jsonable(self.service.health()), correlation_id)
if path == "/readyz":
@ -1168,6 +1189,32 @@ class PortalApplication:
if not supplied or not expected or not secrets.compare_digest(supplied, expected):
raise AuthorizationDenied("invalid registration CSRF token")
def _accept_registration_attempt(self, environ: Mapping[str, Any]) -> bool:
"""Apply a bounded per-source sliding-window limit to public writes.
Only ``REMOTE_ADDR`` is trusted. Forwarded headers are deliberately
ignored because the ingress must normalize the peer address before the
request reaches this process.
"""
source = str(environ.get("REMOTE_ADDR") or "unknown")[:128]
now = monotonic()
cutoff = now - self.registration_rate_window_seconds
with self._registration_attempts_lock:
attempts = self._registration_attempts.setdefault(source, deque())
while attempts and attempts[0] <= cutoff:
attempts.popleft()
if len(attempts) >= self.registration_rate_limit:
return False
attempts.append(now)
if len(self._registration_attempts) > 4096:
for key in tuple(self._registration_attempts):
values = self._registration_attempts[key]
while values and values[0] <= cutoff:
values.popleft()
if not values:
del self._registration_attempts[key]
return True
@staticmethod
def _page(environ: Mapping[str, Any]) -> tuple[int, int]:
query = parse_qs(str(environ.get("QUERY_STRING", "")))