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(",") ).split(",")
if item.strip() 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 json
import re import re
import secrets import secrets
from collections import deque
from threading import Lock
from time import monotonic
from typing import Any, Callable, Iterable, Mapping from typing import Any, Callable, Iterable, Mapping
from urllib.parse import parse_qs, urlencode, urlsplit from urllib.parse import parse_qs, urlencode, urlsplit
@ -75,6 +78,8 @@ class PortalApplication:
registration_tenants: tuple[str, ...] = (), registration_tenants: tuple[str, ...] = (),
registration_oidc_issuer: str = "", registration_oidc_issuer: str = "",
registration_password_setup_origins: tuple[str, ...] = (), registration_password_setup_origins: tuple[str, ...] = (),
registration_rate_limit: int = 10,
registration_rate_window_seconds: int = 60,
) -> None: ) -> None:
if len(trusted_proxy_secret) < 24: if len(trusted_proxy_secret) < 24:
raise ValueError("trusted proxy secret must contain at least 24 characters") raise ValueError("trusted proxy secret must contain at least 24 characters")
@ -93,6 +98,12 @@ class PortalApplication:
self.registration_password_setup_origins = frozenset( self.registration_password_setup_origins = frozenset(
origin.rstrip("/") for origin in registration_password_setup_origins 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]: 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)}" 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]: def _dispatch(self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str) -> Iterable[bytes]:
method = str(environ.get("REQUEST_METHOD", "GET")).upper() method = str(environ.get("REQUEST_METHOD", "GET")).upper()
path = str(environ.get("PATH_INFO", "/")).rstrip("/") or "/" 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": if path == "/healthz":
return self._json(start_response, "200 OK", _jsonable(self.service.health()), correlation_id) return self._json(start_response, "200 OK", _jsonable(self.service.health()), correlation_id)
if path == "/readyz": if path == "/readyz":
@ -1168,6 +1189,32 @@ class PortalApplication:
if not supplied or not expected or not secrets.compare_digest(supplied, expected): if not supplied or not expected or not secrets.compare_digest(supplied, expected):
raise AuthorizationDenied("invalid registration CSRF token") 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 @staticmethod
def _page(environ: Mapping[str, Any]) -> tuple[int, int]: def _page(environ: Mapping[str, Any]) -> tuple[int, int]:
query = parse_qs(str(environ.get("QUERY_STRING", ""))) query = parse_qs(str(environ.get("QUERY_STRING", "")))

View file

@ -2,6 +2,7 @@ import io
import json import json
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from urllib.error import HTTPError
from user_engine.adapters.registration_verification import ( from user_engine.adapters.registration_verification import (
HTTPRegistrationVerificationAdapter, HTTPRegistrationVerificationAdapter,
@ -66,3 +67,22 @@ class RegistrationVerificationAdapterTests(unittest.TestCase):
) )
with self.assertRaisesRegex(RuntimeError, "wrong purpose"): with self.assertRaisesRegex(RuntimeError, "wrong purpose"):
adapter.consume("x" * 32) 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))

View file

@ -30,7 +30,7 @@ SECRET = "test-proxy-secret-with-adequate-length"
def invoke( def invoke(
app, path, *, method="GET", claims=None, marker=SECRET, body=None, 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 = ( payload = (
urlencode(form).encode() urlencode(form).encode()
@ -44,6 +44,7 @@ def invoke(
"CONTENT_LENGTH": str(len(payload)), "CONTENT_LENGTH": str(len(payload)),
"wsgi.input": io.BytesIO(payload), "wsgi.input": io.BytesIO(payload),
"HTTP_X_REQUEST_ID": "corr_test", "HTTP_X_REQUEST_ID": "corr_test",
"REMOTE_ADDR": remote_addr,
} }
if form is not None: if form is not None:
environ["CONTENT_TYPE"] = "application/x-www-form-urlencoded" environ["CONTENT_TYPE"] = "application/x-www-form-urlencoded"
@ -370,6 +371,36 @@ class PortalApplicationTests(unittest.TestCase):
) )
self.assertEqual("400 Bad Request", result["status"]) 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): def test_provision_api_links_provider_subject(self):
self.app.provisioning = FakeProvisioning() self.app.provisioning = FakeProvisioning()
created, payload = invoke( created, payload = invoke(

View file

@ -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 was restored to the last known-good revision without service loss. Final T01
activation remains blocked on approved OpenBao audit/mail receiver tokens and activation remains blocked on approved OpenBao audit/mail receiver tokens and
transactional SMTP credentials; no placeholder or reused credential was added. 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.

View file

@ -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 this image or public registration until the OpenBao delivery and verification
tokens plus transactional SMTP lane are installed. 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 ## T02 - Orchestrate provider identity creation
```task ```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. unlink, and deletion. Verify audit/outbox redaction and correlation.
Done when the full suite and deployed consumer conformance both pass. 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.