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", "")))

View file

@ -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))

View file

@ -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(

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
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.

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
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.