Rate limit public registration writes
This commit is contained in:
parent
cccca324c1
commit
b80de5a1f4
6 changed files with 129 additions and 1 deletions
|
|
@ -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", "")))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue