Rate limit public registration writes
This commit is contained in:
parent
cccca324c1
commit
b80de5a1f4
6 changed files with 129 additions and 1 deletions
|
|
@ -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")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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", "")))
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import io
|
|||
import json
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError
|
||||
|
||||
from user_engine.adapters.registration_verification import (
|
||||
HTTPRegistrationVerificationAdapter,
|
||||
|
|
@ -66,3 +67,22 @@ class RegistrationVerificationAdapterTests(unittest.TestCase):
|
|||
)
|
||||
with self.assertRaisesRegex(RuntimeError, "wrong purpose"):
|
||||
adapter.consume("x" * 32)
|
||||
|
||||
@patch("user_engine.adapters.registration_verification.urlopen")
|
||||
def test_expired_and_replayed_handles_have_the_same_redacted_failure(self, opener):
|
||||
adapter = HTTPRegistrationVerificationAdapter(
|
||||
base_url="http://verification", bearer_token="secret"
|
||||
)
|
||||
messages = []
|
||||
for status in (409, 410):
|
||||
opener.side_effect = HTTPError(
|
||||
"http://verification/consume", status, "provider detail", {},
|
||||
io.BytesIO(b'{"error":"account-specific detail"}'),
|
||||
)
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
adapter.consume("x" * 32)
|
||||
messages.append(str(raised.exception))
|
||||
self.assertEqual(
|
||||
["registration verification was rejected"] * 2, messages
|
||||
)
|
||||
self.assertNotIn("account-specific", repr(messages))
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ SECRET = "test-proxy-secret-with-adequate-length"
|
|||
|
||||
def invoke(
|
||||
app, path, *, method="GET", claims=None, marker=SECRET, body=None,
|
||||
form=None, cookie=None, headers=None, query="",
|
||||
form=None, cookie=None, headers=None, query="", remote_addr="127.0.0.1",
|
||||
):
|
||||
payload = (
|
||||
urlencode(form).encode()
|
||||
|
|
@ -44,6 +44,7 @@ def invoke(
|
|||
"CONTENT_LENGTH": str(len(payload)),
|
||||
"wsgi.input": io.BytesIO(payload),
|
||||
"HTTP_X_REQUEST_ID": "corr_test",
|
||||
"REMOTE_ADDR": remote_addr,
|
||||
}
|
||||
if form is not None:
|
||||
environ["CONTENT_TYPE"] = "application/x-www-form-urlencoded"
|
||||
|
|
@ -370,6 +371,36 @@ class PortalApplicationTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual("400 Bad Request", result["status"])
|
||||
|
||||
def test_public_registration_rate_limit_uses_peer_not_forwarded_header(self):
|
||||
self.app.registration_verification = FakeRegistrationVerification()
|
||||
self.app.registration_rate_limit = 2
|
||||
self.app.registration_rate_window_seconds = 60
|
||||
body = {
|
||||
"username": "person", "email": "person@example.test",
|
||||
"client_id": "unknown", "tenant": "tenant:coulomb",
|
||||
}
|
||||
first, _ = invoke(
|
||||
self.app, "/api/v1/public/registrations", method="POST", body=body,
|
||||
headers={"HTTP_X_FORWARDED_FOR": "198.51.100.1"},
|
||||
)
|
||||
second, _ = invoke(
|
||||
self.app, "/api/v1/public/registrations", method="POST", body=body,
|
||||
headers={"HTTP_X_FORWARDED_FOR": "198.51.100.2"},
|
||||
)
|
||||
limited, payload = invoke(
|
||||
self.app, "/api/v1/public/registrations", method="POST", body=body,
|
||||
headers={"HTTP_X_FORWARDED_FOR": "198.51.100.3"},
|
||||
)
|
||||
other_peer, _ = invoke(
|
||||
self.app, "/api/v1/public/registrations", method="POST", body=body,
|
||||
remote_addr="127.0.0.2",
|
||||
)
|
||||
self.assertEqual("400 Bad Request", first["status"])
|
||||
self.assertEqual("400 Bad Request", second["status"])
|
||||
self.assertEqual("429 Too Many Requests", limited["status"])
|
||||
self.assertEqual("rate_limited", json.loads(payload)["error"]["code"])
|
||||
self.assertEqual("400 Bad Request", other_peer["status"])
|
||||
|
||||
def test_provision_api_links_provider_subject(self):
|
||||
self.app.provisioning = FakeProvisioning()
|
||||
created, payload = invoke(
|
||||
|
|
|
|||
|
|
@ -199,3 +199,9 @@ the required flex-auth and delivery settings are absent. A guarded deployment
|
|||
was restored to the last known-good revision without service loss. Final T01
|
||||
activation remains blocked on approved OpenBao audit/mail receiver tokens and
|
||||
transactional SMTP credentials; no placeholder or reused credential was added.
|
||||
|
||||
2026-08-10 authority check: the deployed tenant-engine contract currently
|
||||
supports tenant creation plus role/plan operations, but exposes no tenant
|
||||
metadata-update or retirement operation. Portal update/retirement routes must
|
||||
remain out until that authority owns the corresponding lifecycle contract;
|
||||
user-engine will not simulate authoritative tenant state locally.
|
||||
|
|
|
|||
|
|
@ -83,6 +83,17 @@ last known-good revision was restored and remained available. Do not activate
|
|||
this image or public registration until the OpenBao delivery and verification
|
||||
tokens plus transactional SMTP lane are installed.
|
||||
|
||||
2026-08-10 security increment: every anonymous registration mutation now has
|
||||
a bounded per-peer sliding-window limit, configurable through
|
||||
`USER_ENGINE_REGISTRATION_RATE_LIMIT` and
|
||||
`USER_ENGINE_REGISTRATION_RATE_WINDOW_SECONDS`. The application deliberately
|
||||
ignores spoofable forwarding headers and relies on the ingress-normalized peer
|
||||
address. Conformance proves isolation between peers and a generic 429 response.
|
||||
Expired and replayed verification handles are also proven to produce the same
|
||||
redacted rejection without provider or account detail. The full suite passes
|
||||
130 tests with three environment-dependent skips. Cluster ingress throttling
|
||||
remains defense in depth before public enablement.
|
||||
|
||||
## T02 - Orchestrate provider identity creation
|
||||
|
||||
```task
|
||||
|
|
@ -179,3 +190,10 @@ replay, account-link collision, cross-tenant profile access, step-up downgrade,
|
|||
unlink, and deletion. Verify audit/outbox redaction and correlation.
|
||||
|
||||
Done when the full suite and deployed consumer conformance both pass.
|
||||
|
||||
The user-engine portion now covers return-context rejection, provider outage
|
||||
and idempotent recovery, resume replay denial, purpose/binding mismatch,
|
||||
verification expiry/replay redaction, peer rate limiting, identity-link
|
||||
collision, cross-tenant denial, lifecycle deletion, audit/outbox redaction,
|
||||
and request correlation. Deployed consumer conformance remains gated on public
|
||||
runtime credentials and activation.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue