diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f86a2fc..8847e8b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] +### Fixed + +- Load-test Inertia visits now send the initial page's asset version and cached i18n catalog marker, measuring real page responses instead of version conflicts and repeated catalog downloads. Invalid bootstrap pages stop the simulated user; 409 responses remain failures. + +### Changed + +- Hosting gates validate their path prefixes once at import via `simple_module_hosting.path_matching.validated_prefixes`; `matches_path` no longer re-checks the trailing slash on every request. Build prefix tuples with `validated_prefixes(...)`. Security headers moved to a private module (import path unchanged) and static headers are applied in one batch. +- Scaffold new modules with `core/` and `web/` areas and mirrored nested Python tests; existing modules migrate individually. See `docs/module-layout.md`. + ### Security - **Behaviour change:** local browser auth mutations now refuse `Sec-Fetch-Site: cross-site` and require the session-bound CSRF token (no Origin/Referer equality check, so TLS proxies work), extending the shared token support (#504). @@ -65,7 +74,6 @@ All notable changes to this project are documented in this file. The format is b fast (server, client, loading/empty/error states, layout shift, polling, prefetch) and how to pin a page's query count in a test. -### Changed - **Breaking:** tenant member lists now use server pagination (`page` / `per_page`, default 25, maximum 200). The current-members API returns `{items, total, page, per_page, pages}` instead of an array; the Members page provides navigation and retains its page after mutations. `TenantService.list_members(tenant_id, params)` now takes `PageParams` and returns `Page[Membership]` instead of a list. - Cache env-supplied settings fields per class and default API version discovery per process; changes to environment, dotenv, or secret sources require a restart. @@ -84,7 +92,6 @@ All notable changes to this project are documented in this file. The format is b - `api_tokens` follows the module layout: `api_tokens.core.{constants,settings,services,tenant_cleanup,user_cleanup}`, `api_tokens.credentials.{format,scopes,owner,service,resolver,audit}` (`tokens.py` is now `credentials/format.py`), `api_tokens.web.{deps,endpoints}`, and the page components and types move to `ui/`. `api_tokens.{constants,service,resolver,tokens}` still import, with a `DeprecationWarning`, until 0.0.46. - `branding` follows the module layout: `branding.core.{constants,settings,services,service,presets,tenant_definitions,tenant_branding}`, `branding.assets.{images,reaper}`, `branding.web.{deps,shared_props,endpoints}`, and the form components move from `components/` to `ui/components/`. `branding.{constants,settings,services,tenant_branding,images}` and `branding.endpoints.assets` still import, with a `DeprecationWarning`, until 0.0.46. -### Fixed - Coalesce concurrent tenant membership and permission grant cache misses. In-flight invalidation retries against fresh authorization data; failures and cancellation release waiting readers. Cache unknown tenant subdomains for five seconds and drop the new slug's host lookups (on a new `tenants.hosts` invalidation channel, so no user id is read as a slug) and the owner's memberships after committed tenant creation; other users' caches are not cleared. Services built outside request dependencies pass `invalidate_hosts=make_host_invalidator(app)` beside `invalidate=make_invalidator(app)`. Retries after concurrent invalidation are bounded to three reads (2026-11-05 local performance review). - Offload S3 server-upload spool I/O, wait for in-flight I/O during cancellation, and reuse one client per multipart upload. Index the default tenant file browse order (migration `f6c2d4e8b109`; local performance review, 2026-11-05). diff --git a/framework/hosting/simple_module_hosting/_observability.py b/framework/hosting/simple_module_hosting/_observability.py index 0ee57bb26..270d1d731 100644 --- a/framework/hosting/simple_module_hosting/_observability.py +++ b/framework/hosting/simple_module_hosting/_observability.py @@ -10,7 +10,7 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send from simple_module_hosting.logging import correlation_id -from simple_module_hosting.path_matching import matches_path +from simple_module_hosting.path_matching import matches_path, validated_prefixes _LOGGER_NAME = "simple_module.request" _request_logger = logging.getLogger(_LOGGER_NAME) @@ -22,7 +22,7 @@ # Paths that produce noisy, low-value log entries _QUIET_EXACT = ("/health",) -_QUIET_PREFIXES = ("/health/", "/static/") +_QUIET_PREFIXES = validated_prefixes("/health/", "/static/") class CorrelationIdMiddleware: diff --git a/framework/hosting/simple_module_hosting/_rate_limit.py b/framework/hosting/simple_module_hosting/_rate_limit.py index 4a6c5a53b..6a23d7c88 100644 --- a/framework/hosting/simple_module_hosting/_rate_limit.py +++ b/framework/hosting/simple_module_hosting/_rate_limit.py @@ -39,7 +39,7 @@ from simple_module_hosting._rate_limit_keys import KEY_IP, KEY_USER_OR_IP, rule_bucket, rule_key from simple_module_hosting._request_guard_responses import send_guard_response -from simple_module_hosting.path_matching import matches_path +from simple_module_hosting.path_matching import matches_path, validated_prefixes from simple_module_hosting.public_access import is_public __all__ = ["RateLimitMiddleware"] @@ -48,7 +48,7 @@ _MESSAGE = "Too many requests. Please slow down and try again shortly." _OPERATIONAL_EXACT = ("/health",) -_OPERATIONAL_PREFIXES = ("/health/", "/static/") +_OPERATIONAL_PREFIXES = validated_prefixes("/health/", "/static/") class RateLimitMiddleware: diff --git a/framework/hosting/simple_module_hosting/_security_headers.py b/framework/hosting/simple_module_hosting/_security_headers.py new file mode 100644 index 000000000..cc8da6a01 --- /dev/null +++ b/framework/hosting/simple_module_hosting/_security_headers.py @@ -0,0 +1,150 @@ +"""Security response headers and per-request CSP nonces.""" + +from __future__ import annotations + +import secrets +from typing import TYPE_CHECKING + +from starlette.datastructures import MutableHeaders +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from simple_module_hosting._csp_dynamic import DynamicCsp + +if TYPE_CHECKING: + from simple_module_core.csp import CspSourceRegistry + +_SCOPE_HTTP = "http" +_MSG_RESPONSE_START = "http.response.start" + +# Security response header names +_HEADER_CSP = "Content-Security-Policy" +_HEADER_HSTS = "Strict-Transport-Security" + +# Security response header values +_XCTO_NOSNIFF = "nosniff" +_XFO_SAMEORIGIN = "SAMEORIGIN" +# OWASP/MDN now recommend disabling the legacy XSS auditor — older +# browser implementations introduced reflected-XSS vectors of their own. +# Production CSP authorizes scripts with a per-request nonce. +_XXSS_DISABLED = "0" +_HTTP_NOT_MODIFIED = 304 +_REFERRER_STRICT_ORIGIN = "strict-origin-when-cross-origin" + + +class SecurityHeadersMiddleware: + """Add security headers to every response. + + ``content_security_policy`` and ``strict_transport_security`` accept a + string to override the defaults, or ``None`` to suppress that header + (useful in development when Vite's HMR client loads cross-origin scripts, + or behind plain-HTTP loopbacks where HSTS would lock users out). + """ + + DEFAULT_CSP = ( + "default-src 'self'; " + # The request nonce is inserted after static/dynamic sources are merged. + # 'wasm-unsafe-eval' lets WebAssembly compile (see worker-src below) + # without re-allowing JavaScript eval. + "script-src 'self' 'strict-dynamic' 'wasm-unsafe-eval'; " + "script-src-elem 'self' 'strict-dynamic'; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "img-src 'self' data: blob:; " + "font-src 'self' https://fonts.gstatic.com data:; " + "connect-src 'self'; " + # Allow blob: workers — MapLibre, comlink, web-tree-sitter and most + # WASM libs ship their worker as a Blob URL. `child-src` is the + # legacy fallback some browsers consult before `worker-src`. + "worker-src 'self' blob:; " + "child-src 'self' blob:; " + "frame-ancestors 'self'; " + "base-uri 'self'; " + "form-action 'self'" + ) + _DEFAULT_HSTS = "max-age=31536000; includeSubDomains" + + @staticmethod + def dev_csp(vite_dev_url: str) -> str: + """Build a dev CSP that whitelists the Vite dev server. + + In development the browser fetches ``@vite/client``, ``main.tsx``, and + React Refresh from the Vite origin (default ``http://localhost:5050``), + and opens a WebSocket there for HMR. Those fail under the prod CSP, + so we widen ``script-src*``/``connect-src``/``style-src`` for that + origin only (including the ``ws://`` equivalent for HMR). + """ + ws_url = vite_dev_url.replace("http://", "ws://").replace("https://", "wss://") + return ( + "default-src 'self'; " + f"script-src 'self' 'unsafe-inline' 'unsafe-eval' {vite_dev_url}; " + f"script-src-elem 'self' 'unsafe-inline' {vite_dev_url}; " + f"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com {vite_dev_url}; " + "img-src 'self' data: blob:; " + "font-src 'self' https://fonts.gstatic.com data:; " + f"connect-src 'self' {vite_dev_url} {ws_url}; " + "worker-src 'self' blob:; " + "child-src 'self' blob:; " + "frame-ancestors 'self'; " + "base-uri 'self'; " + "form-action 'self'" + ) + + def __init__( + self, + app: ASGIApp, + *, + content_security_policy: str | None = DEFAULT_CSP, + strict_transport_security: str | None = _DEFAULT_HSTS, + csp_registry: CspSourceRegistry | None = None, + ) -> None: + self.app = app + self.csp = content_security_policy + self.hsts = strict_transport_security + self._static_headers = ( + (b"x-content-type-options", _XCTO_NOSNIFF.encode("latin-1")), + (b"x-frame-options", _XFO_SAMEORIGIN.encode("latin-1")), + (b"x-xss-protection", _XXSS_DISABLED.encode("latin-1")), + (b"referrer-policy", _REFERRER_STRICT_ORIGIN.encode("latin-1")), + ) + self._static_names = frozenset(name for name, _ in self._static_headers) + # Providers (settings-driven origins, #434) are evaluated per response; + # with none, the precomputed string above is the whole story. + self._dynamic = ( + DynamicCsp(content_security_policy, csp_registry) + if content_security_policy and csp_registry is not None and csp_registry.providers + else None + ) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != _SCOPE_HTTP: + await self.app(scope, receive, send) + return + + nonce = secrets.token_urlsafe(16) + scope.setdefault("state", {})["csp_nonce"] = nonce + + async def send_with_headers(message: Message) -> None: + if message["type"] == _MSG_RESPONSE_START: + # Build a fresh list: the app's own list (or tuple) is never mutated. + raw = [h for h in message.get("headers", ()) if h[0] not in self._static_names] + raw.extend(self._static_headers) + message["headers"] = raw + headers = MutableHeaders(raw=raw) + # A 304 refreshes the cached response's headers: a new nonce there + # would no longer match the cached body, so keep the cached CSP. + if self.csp and message.get("status") != _HTTP_NOT_MODIFIED: + # Separate policies intersect: sandbox responses cannot replace + # the app's default-src, frame-ancestors or other protections. + own = (headers.get(_HEADER_CSP) or "").lower() + keeps_own = any(d.split()[:1] == ["sandbox"] for d in own.split(";")) + dyn = self._dynamic + policy = dyn.policy_for(scope.get("app")) if dyn else self.csp + policy = policy.replace("'strict-dynamic'", f"'nonce-{nonce}' 'strict-dynamic'") + if keeps_own: + headers.append(_HEADER_CSP, policy) + else: + headers[_HEADER_CSP] = policy + if self.hsts: + headers[_HEADER_HSTS] = self.hsts + await send(message) + + await self.app(scope, receive, send_with_headers) diff --git a/framework/hosting/simple_module_hosting/maintenance.py b/framework/hosting/simple_module_hosting/maintenance.py index 5456fe659..bb9753f73 100644 --- a/framework/hosting/simple_module_hosting/maintenance.py +++ b/framework/hosting/simple_module_hosting/maintenance.py @@ -20,14 +20,14 @@ from simple_module_hosting._error_negotiation import _wants_json from simple_module_hosting._error_page import render_error_page -from simple_module_hosting.path_matching import matches_path +from simple_module_hosting.path_matching import matches_path, validated_prefixes from simple_module_hosting.public_access import is_public # Kept reachable while the gate is closed: liveness probes (so orchestrators # do not kill the pod mid-maintenance), static assets and i18n bundles (or the # 503 page renders unstyled and untranslated). _ALWAYS_OPEN_EXACT = ("/health",) -_ALWAYS_OPEN_PREFIXES = ( +_ALWAYS_OPEN_PREFIXES = validated_prefixes( "/health/", "/static/", "/i18n/", diff --git a/framework/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py index 22628c46e..a9778b594 100644 --- a/framework/hosting/simple_module_hosting/middleware.py +++ b/framework/hosting/simple_module_hosting/middleware.py @@ -11,16 +11,13 @@ import contextvars import logging -import secrets from collections.abc import Callable from typing import TYPE_CHECKING, Any from simple_module_core.app_state import get_principal_serializer -from starlette.datastructures import MutableHeaders from starlette.requests import Request -from starlette.types import ASGIApp, Message, Receive, Scope, Send +from starlette.types import ASGIApp, Receive, Scope, Send -from simple_module_hosting._csp_dynamic import DynamicCsp from simple_module_hosting._inertia_shared import ( build_i18n_block, build_menu_translator, @@ -32,6 +29,7 @@ CorrelationIdMiddleware, RequestLoggingMiddleware, ) +from simple_module_hosting._security_headers import SecurityHeadersMiddleware from simple_module_hosting._spa_mount import native_prefixes from simple_module_hosting._tenant import ( TENANT_HEADER, @@ -43,32 +41,12 @@ from simple_module_hosting.token_guard import is_token_principal if TYPE_CHECKING: - from simple_module_core.csp import CspSourceRegistry from simple_module_core.menu import MenuRegistry from simple_module_core.permissions import PermissionRegistry logger = logging.getLogger(__name__) _SCOPE_HTTP = "http" -_MSG_RESPONSE_START = "http.response.start" - -# Security response header names -_HEADER_X_CONTENT_TYPE_OPTIONS = "X-Content-Type-Options" -_HEADER_X_FRAME_OPTIONS = "X-Frame-Options" -_HEADER_X_XSS_PROTECTION = "X-XSS-Protection" -_HEADER_REFERRER_POLICY = "Referrer-Policy" -_HEADER_CSP = "Content-Security-Policy" -_HEADER_HSTS = "Strict-Transport-Security" - -# Security response header values -_XCTO_NOSNIFF = "nosniff" -_XFO_SAMEORIGIN = "SAMEORIGIN" -# OWASP/MDN now recommend disabling the legacy XSS auditor — older -# browser implementations introduced reflected-XSS vectors of their own. -# Production CSP authorizes scripts with a per-request nonce. -_XXSS_DISABLED = "0" -_HTTP_NOT_MODIFIED = 304 -_REFERRER_STRICT_ORIGIN = "strict-origin-when-cross-origin" __all__ = [ "TENANT_HEADER", @@ -82,118 +60,6 @@ ] -class SecurityHeadersMiddleware: - """Add security headers to every response. - - ``content_security_policy`` and ``strict_transport_security`` accept a - string to override the defaults, or ``None`` to suppress that header - (useful in development when Vite's HMR client loads cross-origin scripts, - or behind plain-HTTP loopbacks where HSTS would lock users out). - """ - - DEFAULT_CSP = ( - "default-src 'self'; " - # The request nonce is inserted after static/dynamic sources are merged. - # 'wasm-unsafe-eval' lets WebAssembly compile (see worker-src below) - # without re-allowing JavaScript eval. - "script-src 'self' 'strict-dynamic' 'wasm-unsafe-eval'; " - "script-src-elem 'self' 'strict-dynamic'; " - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " - "img-src 'self' data: blob:; " - "font-src 'self' https://fonts.gstatic.com data:; " - "connect-src 'self'; " - # Allow blob: workers — MapLibre, comlink, web-tree-sitter and most - # WASM libs ship their worker as a Blob URL. `child-src` is the - # legacy fallback some browsers consult before `worker-src`. - "worker-src 'self' blob:; " - "child-src 'self' blob:; " - "frame-ancestors 'self'; " - "base-uri 'self'; " - "form-action 'self'" - ) - _DEFAULT_HSTS = "max-age=31536000; includeSubDomains" - - @staticmethod - def dev_csp(vite_dev_url: str) -> str: - """Build a dev CSP that whitelists the Vite dev server. - - In development the browser fetches ``@vite/client``, ``main.tsx``, and - React Refresh from the Vite origin (default ``http://localhost:5050``), - and opens a WebSocket there for HMR. Those fail under the prod CSP, - so we widen ``script-src*``/``connect-src``/``style-src`` for that - origin only (including the ``ws://`` equivalent for HMR). - """ - ws_url = vite_dev_url.replace("http://", "ws://").replace("https://", "wss://") - return ( - "default-src 'self'; " - f"script-src 'self' 'unsafe-inline' 'unsafe-eval' {vite_dev_url}; " - f"script-src-elem 'self' 'unsafe-inline' {vite_dev_url}; " - f"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com {vite_dev_url}; " - "img-src 'self' data: blob:; " - "font-src 'self' https://fonts.gstatic.com data:; " - f"connect-src 'self' {vite_dev_url} {ws_url}; " - "worker-src 'self' blob:; " - "child-src 'self' blob:; " - "frame-ancestors 'self'; " - "base-uri 'self'; " - "form-action 'self'" - ) - - def __init__( - self, - app: ASGIApp, - *, - content_security_policy: str | None = DEFAULT_CSP, - strict_transport_security: str | None = _DEFAULT_HSTS, - csp_registry: CspSourceRegistry | None = None, - ) -> None: - self.app = app - self.csp = content_security_policy - self.hsts = strict_transport_security - # Providers (settings-driven origins, #434) are evaluated per response; - # with none, the precomputed string above is the whole story. - self._dynamic = ( - DynamicCsp(content_security_policy, csp_registry) - if content_security_policy and csp_registry is not None and csp_registry.providers - else None - ) - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope["type"] != _SCOPE_HTTP: - await self.app(scope, receive, send) - return - - nonce = secrets.token_urlsafe(16) - scope.setdefault("state", {})["csp_nonce"] = nonce - - async def send_with_headers(message: Message) -> None: - if message["type"] == _MSG_RESPONSE_START: - headers = MutableHeaders(scope=message) - headers[_HEADER_X_CONTENT_TYPE_OPTIONS] = _XCTO_NOSNIFF - headers[_HEADER_X_FRAME_OPTIONS] = _XFO_SAMEORIGIN - headers[_HEADER_X_XSS_PROTECTION] = _XXSS_DISABLED - headers[_HEADER_REFERRER_POLICY] = _REFERRER_STRICT_ORIGIN - # A 304 refreshes the cached response's headers: a new nonce there - # would no longer match the cached body, so keep the cached CSP. - if self.csp and message.get("status") != _HTTP_NOT_MODIFIED: - # Separate policies intersect: sandbox responses cannot replace - # the app's default-src, frame-ancestors or other protections. - own = (headers.get(_HEADER_CSP) or "").lower() - keeps_own = any(d.split()[:1] == ["sandbox"] for d in own.split(";")) - dyn = self._dynamic - policy = dyn.policy_for(scope.get("app")) if dyn else self.csp - policy = policy.replace("'strict-dynamic'", f"'nonce-{nonce}' 'strict-dynamic'") - if keeps_own: - headers.append(_HEADER_CSP, policy) - else: - headers[_HEADER_CSP] = policy - if self.hsts: - headers[_HEADER_HSTS] = self.hsts - await send(message) - - await self.app(scope, receive, send_with_headers) - - PrincipalSerializer = Callable[[Any], dict[str, Any]] """Module-owned projection from ``request.state.user`` to the ``auth.user`` shared-prop dict. Framework never inspects user fields beyond ``roles``; a diff --git a/framework/hosting/simple_module_hosting/path_matching.py b/framework/hosting/simple_module_hosting/path_matching.py index c20cc3d32..317a61ea7 100644 --- a/framework/hosting/simple_module_hosting/path_matching.py +++ b/framework/hosting/simple_module_hosting/path_matching.py @@ -1,8 +1,13 @@ """Exact paths and directory prefixes for framework request gates.""" -def matches_path(path: str, *, exact: tuple[str, ...] = (), prefixes: tuple[str, ...] = ()) -> bool: - """Match exact paths or prefixes ending in a slash.""" +def validated_prefixes(*prefixes: str) -> tuple[str, ...]: + """Validate directory boundaries once when building a gate's prefixes.""" if any(not prefix.endswith("/") for prefix in prefixes): raise ValueError("Path prefixes must end with '/'.") + return prefixes + + +def matches_path(path: str, *, exact: tuple[str, ...] = (), prefixes: tuple[str, ...] = ()) -> bool: + """Match exact paths or directory prefixes built with ``validated_prefixes``.""" return path in exact or path.startswith(prefixes) diff --git a/framework/hosting/simple_module_hosting/setup_gate.py b/framework/hosting/simple_module_hosting/setup_gate.py index 6674fe0e1..88c1e1c70 100644 --- a/framework/hosting/simple_module_hosting/setup_gate.py +++ b/framework/hosting/simple_module_hosting/setup_gate.py @@ -24,7 +24,7 @@ from simple_module_hosting import migrations from simple_module_hosting._deferred_startup import run_deferred_startup from simple_module_hosting.data_compatibility import DataCompatibilityError -from simple_module_hosting.path_matching import matches_path +from simple_module_hosting.path_matching import matches_path, validated_prefixes logger = logging.getLogger(__name__) _migration_failure_warned = False @@ -47,7 +47,7 @@ # list is mirrored by the anonymous-access rule in ``attach_public_routes`` — # both lists use exact paths and slash-terminated prefixes. _EXEMPT_EXACT = (SETUP_PATH, "/health") -_EXEMPT_PREFIXES = (f"{SETUP_PATH}/", "/static/", "/health/") +_EXEMPT_PREFIXES = validated_prefixes(f"{SETUP_PATH}/", "/static/", "/health/") STEP_MIGRATIONS = "host.migrations" diff --git a/framework/hosting/tests/test_hosting_public_path_boundaries.py b/framework/hosting/tests/test_hosting_public_path_boundaries.py index 39d8463a4..fbf3279c8 100644 --- a/framework/hosting/tests/test_hosting_public_path_boundaries.py +++ b/framework/hosting/tests/test_hosting_public_path_boundaries.py @@ -73,5 +73,10 @@ async def __call__(self, scope, receive, send): def test_path_matcher_requires_slash_terminated_prefixes(): + from simple_module_hosting.path_matching import validated_prefixes + with pytest.raises(ValueError): - matches_path("/healthcare", prefixes=("/health",)) + validated_prefixes("/health") + prefixes = validated_prefixes("/health/", "/static/") + assert matches_path("/health/ready", prefixes=prefixes) + assert not matches_path("/healthcare", prefixes=prefixes) diff --git a/framework/hosting/tests/test_hosting_security_static_headers.py b/framework/hosting/tests/test_hosting_security_static_headers.py new file mode 100644 index 000000000..621b45f12 --- /dev/null +++ b/framework/hosting/tests/test_hosting_security_static_headers.py @@ -0,0 +1,42 @@ +"""Static security headers replace all endpoint values without losing other headers.""" + +import pytest +from simple_module_hosting.middleware import SecurityHeadersMiddleware + + +@pytest.mark.parametrize("hsts", [None, "max-age=123"]) +async def test_security_static_headers_replace_duplicates(hsts): + messages = [] + + async def app(scope, receive, send): + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + (b"x-frame-options", b"DENY"), + (b"x-frame-options", b"ALLOWALL"), + (b"x-content-type-options", b"endpoint-value"), + (b"x-xss-protection", b"endpoint-value"), + (b"referrer-policy", b"endpoint-value"), + (b"set-cookie", b"a=1"), + (b"set-cookie", b"b=2"), + (b"strict-transport-security", b"endpoint-value"), + ], + } + ) + + async def send(message): + messages.append(message) + + middleware = SecurityHeadersMiddleware(app, strict_transport_security=hsts) + await middleware({"type": "http"}, None, send) + headers = messages[0]["headers"] + assert [v for k, v in headers if k == b"x-frame-options"] == [b"SAMEORIGIN"] + assert [v for k, v in headers if k == b"set-cookie"] == [b"a=1", b"b=2"] + assert dict(headers)[b"x-content-type-options"] == b"nosniff" + assert dict(headers)[b"x-xss-protection"] == b"0" + assert dict(headers)[b"referrer-policy"] == b"strict-origin-when-cross-origin" + assert dict(headers)[b"strict-transport-security"] == ( + hsts.encode() if hsts else b"endpoint-value" + ) diff --git a/modules/auth/auth/middleware.py b/modules/auth/auth/middleware.py index 96e1ace0e..7945629a0 100644 --- a/modules/auth/auth/middleware.py +++ b/modules/auth/auth/middleware.py @@ -15,7 +15,7 @@ from simple_module_core.redirect_safety import SESSION_NEXT_KEY, safe_next_or_none from simple_module_db.listeners import current_user_id from simple_module_hosting.inertia_deps import is_inertia_request -from simple_module_hosting.path_matching import matches_path +from simple_module_hosting.path_matching import matches_path, validated_prefixes from simple_module_hosting.public_access import is_public as route_is_public from starlette.requests import Request from starlette.responses import JSONResponse, RedirectResponse @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) -_FRAMEWORK_PUBLIC_PREFIXES = ( +_FRAMEWORK_PUBLIC_PREFIXES = validated_prefixes( "/health/", "/static/", "/api/docs/", diff --git a/modules/site_lock/site_lock/web/middleware.py b/modules/site_lock/site_lock/web/middleware.py index 0d5d70f73..2c67abcdd 100644 --- a/modules/site_lock/site_lock/web/middleware.py +++ b/modules/site_lock/site_lock/web/middleware.py @@ -18,7 +18,7 @@ from urllib.parse import quote from simple_module_core.app_state import get_auth_provider -from simple_module_hosting.path_matching import matches_path +from simple_module_hosting.path_matching import matches_path, validated_prefixes from starlette.requests import Request from starlette.responses import ( HTMLResponse, @@ -33,6 +33,7 @@ logger = logging.getLogger(__name__) +_HEALTH_PREFIXES = validated_prefixes(c.HEALTH_PREFIX) _NO_STORE = {"Cache-Control": "no-store"} _ERR_WRONG = "Incorrect password." _ERR_THROTTLED = "Too many attempts. Try again later." @@ -69,7 +70,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: path: str = scope["path"] # Kubernetes probes must never be gated, or the pod gets killed. - if matches_path(path, exact=(c.HEALTH_PATH,), prefixes=(c.HEALTH_PREFIX,)): + if matches_path(path, exact=(c.HEALTH_PATH,), prefixes=_HEALTH_PREFIXES): await self.app(scope, receive, send) return diff --git a/scripts/bench_loadtest_overheads.py b/scripts/bench_loadtest_overheads.py new file mode 100644 index 000000000..9c6642e2e --- /dev/null +++ b/scripts/bench_loadtest_overheads.py @@ -0,0 +1,109 @@ +"""Offline request micro-benchmarks; run with ``uv run python scripts/bench_loadtest_overheads.py``. + +Median of five samples; no servers, database connections or network traffic. +Fresh statements model the query objects built for each request, rather than +repeatedly timing SQLAlchemy's memoized key on a single statement. +""" + +import asyncio +import json +from base64 import b64encode +from statistics import median +from timeit import repeat + +from feature_flags.contracts.schemas import FeatureFlagView +from itsdangerous import TimestampSigner +from simple_module_db._core_walk import references_plain +from simple_module_db.base import create_module_base +from simple_module_db.mixins import MultiTenantMixin, SoftDeleteMixin +from simple_module_hosting.middleware import SecurityHeadersMiddleware +from simple_module_hosting.path_matching import matches_path +from simple_module_hosting.session import SessionMiddleware +from sqlalchemy import select +from sqlmodel import Field + +Base = create_module_base("bench_loadtest") + + +class Item(Base, MultiTenantMixin, SoftDeleteMixin, table=True): # ty: ignore[unsupported-base] + __tablename__ = "bench_loadtest_item" + id: int | None = Field(default=None, primary_key=True) + + +def bench(label, call, number=20000): + samples = repeat(call, number=number, repeat=5) + print(f"{label}: {median(samples) / number * 1e6:.3f} us") + + +async def response(scope, receive, send): + # Include endpoint headers so overriding them is part of the measurement. + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + (b"content-type", b"application/json"), + (b"vary", b"Cookie"), + (b"x-frame-options", b"DENY"), + ], + } + ) + await send({"type": "http.response.body", "body": b"{}"}) + + +async def sink(message): + pass + + +async def middleware_bench(label, app, headers): + samples = [] + loop = asyncio.get_running_loop() + for _ in range(5): + start = loop.time() + for _ in range(10000): + await app( + {"type": "http", "scheme": "https", "path": "/", "headers": headers}, sink, sink + ) + samples.append((loop.time() - start) / 10000 * 1e6) + print(f"{label}: {median(samples):.3f} us") + + +async def main(): + bench( + "path match", + lambda: matches_path("/dashboard/", exact=("/health",), prefixes=("/health/", "/static/")), + ) + values = { + "name": "reports", + "description": "Reports", + "default_enabled": False, + "enabled": True, + "overridden": True, + "system_enabled": None, + } + bench("DTO validate", lambda: FeatureFlagView(**values)) + bench("DTO construct", lambda: FeatureFlagView.model_construct(**values)) + assert ( + FeatureFlagView(**values).model_dump() + == FeatureFlagView.model_construct(**values).model_dump() + ) + names = {"bench_loadtest_item"} + decisions = {select(Item).where(Item.id == 1)._generate_cache_key().key: False} + assert not references_plain(select(Item).where(Item.id == 1), names) + bench("fresh ORM walk", lambda: references_plain(select(Item).where(Item.id == 1), names)) + bench("fresh ORM cache key", lambda: select(Item).where(Item.id == 1)._generate_cache_key()) + bench( + "fresh ORM cached decision", + lambda: decisions[select(Item).where(Item.id == 1)._generate_cache_key().key], + ) + await middleware_bench("security wrapper", SecurityHeadersMiddleware(response), []) + cookie = TimestampSigner("benchmark").sign(b64encode(json.dumps({"user_id": "u1"}).encode())) + await middleware_bench( + "unchanged session wrapper", + SessionMiddleware(response, secret_key="benchmark"), + [(b"cookie", b"session=" + cookie)], + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/loadtest/README.md b/tests/loadtest/README.md index 1f09da286..3d76f26cd 100644 --- a/tests/loadtest/README.md +++ b/tests/loadtest/README.md @@ -56,6 +56,26 @@ make loadtest # headless against $(LOCUST_ # or override: make loadtest LOCUST_ARGS="-u 50 -r 10 -t 60s" ``` +Each simulated user first loads `/dashboard/` as HTML. The scenario reads the +asset version and authenticated i18n catalog from the Inertia v3 JSON script, +then sends `X-Inertia-Version` and `X-SM-I18n: :full` on subsequent +Inertia visits, like a browser tab with a cached catalog. This initial HTML +request has its own stats row. Missing/invalid bootstrap data or an anonymous +catalog records a failure and stops that user. A 409 version conflict remains +a failure; restart the run after an asset deployment. + +Older runs omitted both headers: the two Inertia tasks (20 weight points) +returned 409 instead of exercising their endpoints, and successful +page responses could include the full catalog (~71 KB). Their latency, +throughput and failure rates are not comparable with corrected runs. + +**Size the pool before increasing concurrency.** At 300 users the default +`10 + 20` connection pool can be exhausted; queuing or pool timeouts then +dominate the result. Account for every worker's pool and Postgres's connection +limit, as described in [Deployment: pool sizing](../../docs/reference/deployment.md). +Record worker count, pool size/overflow, database size and asset version with +each result. Concurrency is not a reason to allocate one connection per user. + ## 4. Profile allocations under load (memray) `make loadtest-memray` seeds the auth cookie, starts uvicorn under memray, @@ -74,3 +94,25 @@ blocks attaching to a running process without root) and drive it with locust: uvx py-spy record --format speedscope --subprocesses --duration 40 -o profile.json -- \ uv run --project host uvicorn host.main:app --port 8000 --host 127.0.0.1 ``` + +## Offline micro-benchmarks + +`uv run python scripts/bench_loadtest_overheads.py` measures request helpers +without starting services. It reports median microseconds over five samples; +statement measurements build fresh query objects, as requests do. These +measurements demonstrate local CPU costs, not end-to-end throughput gains. + +Local measurements on 2026-11-05 (same process setup before/after): + +| Candidate | Before → after (µs) | Decision | +|---|---:|---| +| Validate path prefixes at tuple construction | 0.757 → 0.223 | Kept; all four gate callers validate once | +| Pre-encode and batch four static security headers (full wrapper) | 14.402 → 11.198 | Kept; CSP nonces, providers and HSTS still resolve as before | +| Construct flag DTO without validation | 4.693 → 4.633 | Dropped; ~1% was not a consistent gain across runs | +| Cache statement walk decision by SQLAlchemy key (fresh ORM query) | 45.925 → 48.822 | Dropped; key generation and lookup cost more than the walk | +| Skip session Vary normalization for a single token | 2.832 → 3.628 | Dropped; regresses the comma-separated Vary case (although a single Cookie token benefits) | + +The installed Starlette session middleware already avoids re-signing unchanged +sessions. No session or query-filter code changed; tenancy and soft-delete +criteria remain untouched. The cache experiment timed a warmed lookup even +without the additional registry/policy invalidation a production cache needs. diff --git a/tests/loadtest/locustfile.py b/tests/loadtest/locustfile.py index 231a1da7e..176a55fce 100644 --- a/tests/loadtest/locustfile.py +++ b/tests/loadtest/locustfile.py @@ -13,16 +13,38 @@ from __future__ import annotations +import json import os import random +from html.parser import HTMLParser from locust import HttpUser, between, task +from locust.exception import StopUser _COOKIE = os.environ.get("SM_LOADTEST_COOKIE", "") -_INERTIA = {"X-Inertia": "true"} _SEARCH_TERMS = ("john", "smith", "maria", "lee", "garcia", "son", "er", "a") +class _PageParser(HTMLParser): + """Read the v3 client's JSON script, including split parser data chunks.""" + + def __init__(self) -> None: + super().__init__() + self.in_page = False + self.chunks: list[str] = [] + + def handle_starttag(self, tag, attrs) -> None: + self.in_page = tag == "script" and dict(attrs).get("data-page") == "app" + + def handle_endtag(self, tag) -> None: + if tag == "script": + self.in_page = False + + def handle_data(self, data) -> None: + if self.in_page: + self.chunks.append(data) + + class AuthedUser(HttpUser): """Authenticated browser-like user driving a weighted endpoint mix.""" @@ -35,13 +57,52 @@ def on_start(self) -> None: 'scripts/loadtest_seed.py)"` first (make loadtest-memray does this).' ) self.client.cookies.set("session", _COOKIE) + with self.client.get( + "/dashboard/", + headers={"Accept": "text/html"}, + name="/dashboard/ (initial HTML)", + catch_response=True, + ) as response: + try: + if response.status_code != 200: + raise ValueError(f"Initial HTML returned {response.status_code}") + parser = _PageParser() + parser.feed(response.text) + page = json.loads("".join(parser.chunks)) + version = page["version"] + catalog = page["props"]["i18n"] + if not isinstance(version, str) or not version: + raise ValueError("Initial page has no asset version") + if ( + not isinstance(catalog, dict) + or not isinstance(catalog.get("messages"), dict) + or catalog.get("audience") != "full" + or not isinstance(catalog.get("locale"), str) + or not catalog["locale"] + ): + raise ValueError("Initial page has no authenticated catalog") + self._inertia = { + "X-Inertia": "true", + "X-Inertia-Version": version, + "X-SM-I18n": f"{catalog['locale']}:{catalog['audience']}", + } + except (ValueError, KeyError, TypeError) as exc: + response.failure(f"Cannot initialize browser: {exc}") + raise StopUser from exc + + def _visit(self, path: str, name: str) -> None: + with self.client.get( + path, headers=self._inertia, name=name, catch_response=True + ) as response: + if response.status_code == 409: + response.failure("Inertia asset version conflict; restart the load test") # Weights approximate a real admin browsing session: lots of list/detail # reads, fewer dashboard hits. `name=` groups stats so paginated URLs with # varying query strings don't explode the Locust stats table. @task(10) def dashboard_view(self) -> None: - self.client.get("/dashboard/", headers=_INERTIA, name="/dashboard/") + self._visit("/dashboard/", "/dashboard/") @task(5) def dashboard_stats(self) -> None: @@ -55,9 +116,7 @@ def users_list_api(self) -> None: @task(10) def users_list_view(self) -> None: page = random.randint(1, 50) - self.client.get( - f"/admin/users/?page={page}&per_page=20", headers=_INERTIA, name="/admin/users/" - ) + self._visit(f"/admin/users/?page={page}&per_page=20", "/admin/users/") @task(8) def users_search(self) -> None: diff --git a/tests/loadtest/test_loadtest_inertia_client.py b/tests/loadtest/test_loadtest_inertia_client.py new file mode 100644 index 000000000..c0d698431 --- /dev/null +++ b/tests/loadtest/test_loadtest_inertia_client.py @@ -0,0 +1,108 @@ +"""The load scenario must exercise successful, catalog-cached Inertia visits.""" + +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest +from requests import Response +from requests.adapters import BaseAdapter + + +@pytest.fixture +def scenario(monkeypatch): + monkeypatch.setenv("LOCUST_SKIP_MONKEY_PATCH", "1") + spec = importlib.util.spec_from_file_location( + "load_scenario", Path(__file__).with_name("locustfile.py") + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.setattr(module, "_COOKIE", "test-cookie") + return module + + +def user_for(scenario, text, status=200): + from locust.env import Environment + from locust.event import Events + + state = SimpleNamespace(text=text, status=status, calls=[], results=[]) + + class OfflineAdapter(BaseAdapter): + def send(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None): + state.calls.append(request) + response = Response() + response.status_code = state.status + response._content = state.text.encode() + response.url = request.url + response.request = request + return response + + def close(self): + pass + + environment = Environment(events=Events()) + environment.events.request.add_listener(lambda **result: state.results.append(result)) + scenario.AuthedUser.host = "http://loadtest.invalid" + user = scenario.AuthedUser(environment) + user.client.mount("http://", OfflineAdapter()) + return user, state + + +def page_html(version="asset-123", locale="de", audience="full"): + page = { + "version": version, + "props": {"i18n": {"locale": locale, "audience": audience, "messages": {"hello": "Hallo"}}}, + } + return ( + f'' + '
' + ) + + +def test_loadtest_bootstraps_and_sends_browser_headers(scenario): + user, state = user_for(scenario, page_html()) + user.on_start() + assert state.calls[0].path_url == "/dashboard/" + assert state.calls[0].headers["Accept"] == "text/html" + assert state.calls[0].headers["Cookie"] == "session=test-cookie" + assert "X-Inertia" not in state.calls[0].headers + for task in (user.dashboard_view, user.users_list_view): + task() + assert {k: v for k, v in state.calls[-1].headers.items() if k.startswith("X-")} == { + "X-Inertia": "true", + "X-Inertia-Version": "asset-123", + "X-SM-I18n": "de:full", + } + assert state.results[-1]["exception"] is None + user.dashboard_stats() + assert "X-Inertia-Version" not in state.calls[-1].headers + assert "X-SM-I18n" not in state.calls[-1].headers + + +@pytest.mark.parametrize( + "text,status", + [ + ("login page", 200), + (page_html(), 409), + (page_html(version=None), 200), + (page_html(audience="public"), 200), + (page_html(locale=None), 200), + ('', 200), + ], +) +def test_loadtest_stops_on_invalid_bootstrap(scenario, text, status): + from locust.exception import StopUser + + user, state = user_for(scenario, text, status) + with pytest.raises(StopUser): + user.on_start() + assert state.results[-1]["exception"] is not None + + +def test_loadtest_does_not_accept_version_conflicts(scenario): + user, state = user_for(scenario, page_html()) + user.on_start() + state.status = 409 + user.dashboard_view() + assert state.results[-1]["exception"] is not None