Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand All @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions framework/hosting/simple_module_hosting/_observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions framework/hosting/simple_module_hosting/_rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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:
Expand Down
150 changes: 150 additions & 0 deletions framework/hosting/simple_module_hosting/_security_headers.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions framework/hosting/simple_module_hosting/maintenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
Loading
Loading