From b80de5a1f426f19ad6788ac764cd2265d91627e4 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 10 Aug 2026 17:52:53 +0200 Subject: [PATCH] Rate limit public registration writes --- src/user_engine/runtime.py | 6 +++ src/user_engine/web.py | 47 +++++++++++++++++++ .../test_registration_verification_adapter.py | 20 ++++++++ tests/test_web.py | 33 ++++++++++++- .../USER-WP-0021-portal-product-expansion.md | 6 +++ ...gistration-and-jit-application-profiles.md | 18 +++++++ 6 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py index 6069d48..42c7bfc 100644 --- a/src/user_engine/runtime.py +++ b/src/user_engine/runtime.py @@ -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") + ), ) diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 37a3dff..b5217bd 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -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", ""))) diff --git a/tests/test_registration_verification_adapter.py b/tests/test_registration_verification_adapter.py index 6d9f990..12a749b 100644 --- a/tests/test_registration_verification_adapter.py +++ b/tests/test_registration_verification_adapter.py @@ -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)) diff --git a/tests/test_web.py b/tests/test_web.py index 679c875..c4c524a 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -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( diff --git a/workplans/USER-WP-0021-portal-product-expansion.md b/workplans/USER-WP-0021-portal-product-expansion.md index b0b8610..4085b28 100644 --- a/workplans/USER-WP-0021-portal-product-expansion.md +++ b/workplans/USER-WP-0021-portal-product-expansion.md @@ -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. diff --git a/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md b/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md index 79fb827..4fe0b3f 100644 --- a/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md +++ b/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md @@ -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.