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(",")
|
).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")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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", "")))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue