diff --git a/CLAUDE.md b/CLAUDE.md index faf5327b..069a0f04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ hence `SM022`/`SM023`. See `docs/module-authoring.md` § Styling. `MenuRegistry.add_provider(fn)` (from `register_menu_items`) contributes per-request menu items evaluated in `InertiaLayoutDataMiddleware` after auth/tenant resolution, and `PermissionRegistry.add_source(name, provider)` (from `register_permissions`) contributes runtime-defined permissions from a sync in-memory cache, refreshed with `invalidate_source(name)`; see [docs/framework/permissions.md](docs/framework/permissions.md). **Middleware pipeline** (Starlette `add_middleware` is LIFO — last added runs first). Execution order on a request: -`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → GZip → SecurityHeaders → Session → → Tenant (opt-in) → Locale → InertiaLayoutData → InertiaCache → Setup → Maintenance → CommitBeforeResponse → app`. `InertiaCache` answers for `InertiaLayoutData` merging per-user `auth`/`menus` into every payload: a response to an `X-Inertia` request is forced to `private, no-store` with its ETag dropped, and both representations of a URL gain `Vary: X-Inertia` — so no cache can store the JSON payload or hand it back for a page request. A module wanting its public page content cached should set `Cache-Control` and an ETag on the *document*; that path is left alone. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs). Inertia does not depend on it: the page url is rewritten to the root-relative form the protocol specifies (`_inertia_url.py`), so no scheme travels in the payload to disagree with the document's — the cross-scheme `pushState` `SecurityError` of GH #223 cannot recur on an install that never set the variable. When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names. `Maintenance` serves a 503 page to everyone but admins while `maintenance_mode` is set on `HostSettings`; it sits inside `InertiaCache` because its 503 is an Inertia payload produced by short-circuiting, and outside the cache guard that payload would ship storable. `Setup` runs just before it, for the same cache reason and because an install that was never set up has nothing meaningful to put into maintenance. +`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → BodyLimit → GZip → SecurityHeaders → Session → → Tenant (opt-in) → Locale → InertiaLayoutData → InertiaCache → RateLimit → Setup → Maintenance → CommitBeforeResponse → app`. `BodyLimit` refuses an oversized request body (`HostSettings.max_request_body_bytes`, default 10 MiB; per-path overrides via `register_body_limits`) on `Content-Length` or by counting streamed chunks, before GZip and every module see it; `RateLimit` sits *after* auth and Locale/layout data so it can tell anonymous from signed-in and render a translated 429, and by default throttles only anonymous requests to routes the public-route registry exempts (`rate_limit_public`, per client IP, Redis via `SM_REDIS_URL` else per-worker). See [docs/framework/request-guards.md](docs/framework/request-guards.md). `InertiaCache` answers for `InertiaLayoutData` merging per-user `auth`/`menus` into every payload: a response to an `X-Inertia` request is forced to `private, no-store` with its ETag dropped, and both representations of a URL gain `Vary: X-Inertia` — so no cache can store the JSON payload or hand it back for a page request. A module wanting its public page content cached should set `Cache-Control` and an ETag on the *document*; that path is left alone. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs). Inertia does not depend on it: the page url is rewritten to the root-relative form the protocol specifies (`_inertia_url.py`), so no scheme travels in the payload to disagree with the document's — the cross-scheme `pushState` `SecurityError` of GH #223 cannot recur on an install that never set the variable. When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names. `Maintenance` serves a 503 page to everyone but admins while `maintenance_mode` is set on `HostSettings`; it sits inside `InertiaCache` because its 503 is an Inertia payload produced by short-circuiting, and outside the cache guard that payload would ship storable. `Setup` runs just before it, for the same cache reason and because an install that was never set up has nothing meaningful to put into maintenance. **Database**: per-module `Base` via `create_module_base("")`. On SQLite `init_db` enables WAL, an explicit `busy_timeout`, and **`foreign_keys=ON`** — so both sides of an FK must use the same column type (`sa.Uuid` and fastapi-users' `GUID` are identical on Postgres but not on SQLite), and parents must be flushed before children. Every module owns its own `MetaData` (so Alembic autogenerate can attribute tables to a module), but all tables live in the host's single schema. `__tablename__` must be prefixed with the module name to avoid collisions (`orders_order`). Postgres and SQLite share the same layout. diff --git a/docs/framework/lifecycle.md b/docs/framework/lifecycle.md index 6fb4148e..73b411bc 100644 --- a/docs/framework/lifecycle.md +++ b/docs/framework/lifecycle.md @@ -13,6 +13,7 @@ register_event_handlers register_invalidations register_health_checks register_public_routes +register_body_limits register_csp_sources register_design_packs register_audit_links @@ -147,6 +148,19 @@ async def _check_db(self) -> HealthCheckResult: ... Each check returns a `HealthCheckResult(status=HealthStatus.HEALTHY | DEGRADED | UNHEALTHY, detail=...)`. The `/health/ready` endpoint runs all checks concurrently and reports the worst status (a raising check counts as `UNHEALTHY`). +## `register_body_limits(registry)` + +Raise (or lower) the request-body ceiling for specific routes — the host +refuses any body over `max_request_body_bytes` (10 MiB by default) with a 413 +before the route runs. A multipart upload endpoint declares what it needs: + +```python +def register_body_limits(self, registry) -> None: + registry.add_exact("/api/media/upload", 200 * 1024 * 1024, methods={"POST"}) +``` + +See [request-guards.md](request-guards.md). + ## `register_csp_sources(registry)` Whitelist external origins your frontend loads assets from — a font CDN, a @@ -305,6 +319,7 @@ class OrdersModule(ModuleBase): def register_event_handlers(self, bus, app=None): ... def register_health_checks(self, registry): ... def register_public_routes(self, registry): ... + def register_body_limits(self, registry): ... def register_csp_sources(self, registry): ... def register_exception_handlers(self, app): ... def register_middleware(self, app): ... diff --git a/docs/framework/middleware.md b/docs/framework/middleware.md index a58b9ddc..5ca40121 100644 --- a/docs/framework/middleware.md +++ b/docs/framework/middleware.md @@ -11,6 +11,7 @@ The actual `add_middleware` call order (in `install_middleware`) is the # Added first → executed last (closest to the app) app.add_middleware(CommitBeforeResponseMiddleware) app.add_middleware(MaintenanceMiddleware) +app.add_middleware(RateLimitMiddleware, ...) app.add_middleware(InertiaCacheMiddleware) app.add_middleware(InertiaLayoutDataMiddleware, ...) app.add_middleware(LocaleMiddleware, ...) @@ -24,6 +25,7 @@ for module in discovered_modules: app.add_middleware(SessionMiddleware, secret_key=...) app.add_middleware(SecurityHeadersMiddleware, ...) app.add_middleware(GZipMiddleware, minimum_size=...) +app.add_middleware(BodyLimitMiddleware, ...) app.add_middleware(RequestLoggingMiddleware) app.add_middleware(CorrelationIdMiddleware) @@ -41,6 +43,8 @@ CorrelationId ↓ RequestLogging ↓ +BodyLimit (413 on an oversized body) + ↓ GZip ↓ SecurityHeaders @@ -57,6 +61,8 @@ InertiaLayoutData ↓ InertiaCache ↓ +RateLimit (429 for anonymous public-route traffic) + ↓ Maintenance ↓ CommitBeforeResponse @@ -72,6 +78,8 @@ The last three are ordered relative to each other for reasons worth stating, bec - **`Maintenance` runs *after* `InertiaLayoutData`, `Locale` and auth.** It needs the shared props to render with a layout instead of bare, the locale to answer in the right language, and the resolved user to know whether the caller is an admin who should pass through. - **`CommitBeforeResponse` is innermost.** It hooks the `send` channel, so being added first makes its wrapper the first to see the response — which is what lets the commit land before any byte is written. +The two request guards, `BodyLimit` and `RateLimit`, are documented in [request-guards.md](request-guards.md): `BodyLimit` is early so a huge body never reaches GZip or any module, `RateLimit` is late because it needs the auth result and the shared props its error page renders with. + ## What each built-in does ### `ProxyHeadersMiddleware` *(opt-in)* diff --git a/docs/framework/public-routes.md b/docs/framework/public-routes.md index 8d893c6e..bf2b618e 100644 --- a/docs/framework/public-routes.md +++ b/docs/framework/public-routes.md @@ -53,6 +53,19 @@ verb matches. `registry.add(route_or_pattern, *, methods=, kind=)` is the general form; pass a prebuilt `PublicRoute` or a string. +## Rate limiting anonymous callers + +Every anonymous request that matches a public rule is rate-limited per client +IP by the host (`rate_limit_public`, `120/minute` by default). A rule can carry +its own budget, or opt out: + +```python +registry.add_regex(r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"}, rate="600/minute") +registry.add_prefix("/api/gis/stac", rate="off") +``` + +See [request-guards.md](request-guards.md). + ## Why method-awareness matters `/api/gis/datasets/{id}/` carries both reads and mutations: diff --git a/docs/framework/request-guards.md b/docs/framework/request-guards.md new file mode 100644 index 00000000..ed5a2383 --- /dev/null +++ b/docs/framework/request-guards.md @@ -0,0 +1,143 @@ +# Request guards: body size and rate limiting + +Two pipeline-level guards protect the process before a route does any work. +Both are raw ASGI middleware in `simple_module_hosting`, configured from +`HostSettings` (env, then DB, then default) and extended by modules through a +registration hook. + +| Guard | Answers | Default | Module hook | +|---|---|---|---| +| Body size (`BodyLimitMiddleware`) | `413` | 10 MiB | `register_body_limits` | +| Rate limit (`RateLimitMiddleware`) | `429` + `Retry-After` | `120/minute` per IP, anonymous public routes only | `rate=` on a public rule | + +Both settings are read when the middleware stack is built, so editing them in +the admin UI needs a restart (the pre-app settings read picks the DB value up +on the next boot; an env var still wins). + +## Body-size guard + +A module's own payload check runs only after the framework has read the whole +body into memory, so it protects the database, not the process. The guard +refuses first: + +- **`Content-Length` over the ceiling** is refused before anything reads the + body. +- **No `Content-Length` (chunked)**, or one that lies, is caught by counting the + bytes the app pulls through `receive` and aborting once the ceiling is + crossed. A client cannot bypass the guard by streaming. + +It sits right inside `RequestLogging` (so a 413 is logged with its correlation +id) and outside `GZip` and every module middleware. + +### Response shape + +Negotiated like every other error (`_wants_json`): API callers (`/api/*`, or an +explicit JSON `Accept`) get `413 {"detail": "...", "max_bytes": N}` immediately. +A browser request is not refused at the outer layer, because the error page needs +the session, locale and shared props the inner layers set up; instead the body +read raises `HTTPException(413)` and the app's normal handler renders the +Inertia error page. A route that never reads its body is therefore not +interrupted on that path. + +### Settings + +| Field | Env | Default | +|---|---|---| +| `max_request_body_bytes` | `SM_MAX_REQUEST_BODY_BYTES` | `10485760` (10 MiB); `0` disables the guard | + +### Per-path overrides: `register_body_limits` + +```python +def register_body_limits(self, registry: BodyLimitRegistry) -> None: + # fixed ceiling + registry.add_exact("/api/media/upload", 200 * 1024 * 1024, methods={"POST"}) + # a limit that is itself a runtime setting: pass a callable taking the app + registry.add_prefix("/api/x/import", lambda app: app.state.x.settings.max_bytes) +``` + +Helpers: `add` (any match kind), `add_prefix`, `add_exact`, `add_regex`. The +first matching rule in registration order wins and *replaces* the global +ceiling, so it can lower it as well as raise it; `0` means unlimited for that +route. `file_storage` uses this for its upload endpoint: its ceiling is the +`max_file_size_bytes` setting plus 1 MiB of multipart framing. + +## Rate limiter + +Keyed on the client IP as the ASGI scope reports it, so `SM_TRUSTED_PROXY` +(`ProxyHeaders`, outermost) is honoured. It runs **after** the auth middleware +and after `Locale`/`InertiaLayoutData`, so it can tell anonymous from signed-in +and render a translated error page. + +Policy: + +1. A request is limited by `rate_limit_public` only if the auth middleware + judged it anonymous-allowed **and** nobody is signed in. `AuthMiddleware` + records that decision as `scope["state"]["auth_public"]` (framework defaults, + the public-route registry and the provider's legacy public paths), and the + limiter reads the flag, so the two cannot disagree, path variants included. + The key is a plain scope-state contract (no import, SM009-safe). With no auth + provider installed the registry match is used instead. `/health` and + `/static/` are never limited. +2. A matching public rule's own `rate=` replaces that default and gets its own + bucket per client. +3. `rate_limit_authenticated` (blank by default) opts signed-in traffic in. +4. Everything else, including routes outside the registry such as `/health` and + `/static/`, is untouched. + +### Settings + +| Field | Env | Default | +|---|---|---| +| `rate_limit_public` | `SM_RATE_LIMIT_PUBLIC` | `120/minute`; blank or `off` disables | +| `rate_limit_authenticated` | `SM_RATE_LIMIT_AUTHENTICATED` | blank (off) | +| `redis_url` | `SM_REDIS_URL` | unset | + +Rates read `/` with `second`, `minute`, `hour` or `day` +(`5/10s` for a multiple). A malformed rate fails at boot rather than silently +disabling protection. + +Without `SM_TRUSTED_PROXY`, every client behind a reverse proxy appears as the +proxy's address and shares one anonymous bucket. Set it (or raise +`rate_limit_public`) before putting the host behind a proxy. + +### Per-rule override + +```python +def register_public_routes(self, registry) -> None: + registry.add_regex(r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"}, rate="600/minute") + registry.add_prefix("/api/records/public/", rate="60/minute") + registry.add_prefix("/api/gis/stac", rate="off") # exempt this rule +``` + +### Storage + +- **Redis** when `SM_REDIS_URL` is set (needs the `redis` package, which + `background_tasks` already installs): a fixed window, counted atomically with + one Lua `INCR` + `PEXPIRE`, shared by every worker. Short socket timeouts keep + a stalled Redis from hanging requests. +- **In process** otherwise: counters live in one worker, so with N workers the + effective limit is up to N times the configured rate. +- **Degrades, never latches.** If Redis errors (down, timeout), hits are counted + in per-worker counters instead (so the limit is weaker, not gone), a warning is + logged (at most once a minute), and Redis is probed again after 5 seconds, so + recovery is automatic. The script also re-arms a counter that lost its TTL, so + no key can block an IP permanently. The limiter never takes requests down. + +### Response shape + +`429` with a `Retry-After` header (seconds until the window resets). API callers +get `{"detail": "...", "retry_after": N}`; browsers get the Inertia error page. + +### Not covered + +`users/auth_local/rate_limit.py` (`LoginRateLimiter`, `ThroughputLimiter`) stays +as it is: they are synchronous, per-key lockout and per-endpoint budget +primitives used from FastAPI dependencies, a different shape from this +middleware's async per-IP window. + +## Where it lives + +- `simple_module_core.body_limits.BodyLimitRegistry`, `simple_module_core.rate_limit` + (rate parsing, in-process store), `PublicRoute.rate` +- `simple_module_hosting._body_limit`, `_rate_limit`, `_request_guard_responses` +- Wiring: `_phase_helpers.install_middleware`; registry on `app.state.body_limits` diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 0ae04b60..36850c02 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -22,8 +22,9 @@ Prefix is always `SM_`. These are the pre-DB knobs read by `simple_module_hostin | `SM_LOG_FORMAT` | `json` | `json` (structured) or `text`. | | `SM_MODULES_ENABLED` | unset (all enabled) | Comma-separated allow-list to disable modules without uninstalling them. | | `SM_AUTH_PUBLIC_PATHS` | `[]` | JSON array of anonymous-access path prefixes — a host-level escape hatch. Modules should prefer the `register_public_routes` hook. | +| `SM_REDIS_URL` | unset | Optional. Shared store for the rate limiter (and the background-tasks broker). Unset means per-worker in-process counters. See [request guards](../framework/request-guards.md). | -Multi-tenancy (`multi_tenant`, `tenant_header`) and i18n (`i18n_default_locale`, `i18n_supported_locales`, `i18n_cookie_name`) are **DB-backed host settings** now, not env vars — edit them under `host` at `/admin/settings/`. (`smpy new --tenancy` still writes `SM_MULTI_TENANT=true` into `.env.example` as a scaffold convenience, and tests can override these.) +Request guards (`max_request_body_bytes`, `rate_limit_public`, `rate_limit_authenticated`) are DB-backed host settings read at boot; changing them needs a restart. Multi-tenancy (`multi_tenant`, `tenant_header`) and i18n (`i18n_default_locale`, `i18n_supported_locales`, `i18n_cookie_name`) are **DB-backed host settings** now, not env vars — edit them under `host` at `/admin/settings/`. (`smpy new --tenancy` still writes `SM_MULTI_TENANT=true` into `.env.example` as a scaffold convenience, and tests can override these.) ## Database bootstrap knobs diff --git a/framework/core/simple_module_core/__init__.py b/framework/core/simple_module_core/__init__.py index fa1395b6..44aafdb1 100644 --- a/framework/core/simple_module_core/__init__.py +++ b/framework/core/simple_module_core/__init__.py @@ -1,6 +1,7 @@ """SimpleModule Core - Module system, menu, permissions, events, and diagnostics.""" from simple_module_core.audit_links import AuditLink, AuditLinkRegistry, LabelResolver +from simple_module_core.body_limits import BodyLimitRegistry from simple_module_core.csp import CspSourceError, CspSourceRegistry from simple_module_core.design_packs import DesignPack, DesignPackRegistry from simple_module_core.diagnostics import ( @@ -56,6 +57,7 @@ "TENANT_ROLE_PREFIX", "AuditLink", "AuditLinkRegistry", + "BodyLimitRegistry", "CircularDependencyError", "CspSourceError", "CspSourceRegistry", diff --git a/framework/core/simple_module_core/body_limits.py b/framework/core/simple_module_core/body_limits.py new file mode 100644 index 00000000..9601deda --- /dev/null +++ b/framework/core/simple_module_core/body_limits.py @@ -0,0 +1,71 @@ +"""Per-path request-body ceilings modules declare for the body-size guard. + +The host ships one global ceiling (``HostSettings.max_request_body_bytes``). +A module whose endpoint legitimately takes more — a multipart upload — or +should take far less raises or lowers it for that route through +:meth:`~simple_module_core.module.ModuleBase.register_body_limits`:: + + def register_body_limits(self, registry): + registry.add_prefix("/api/file-storage/upload", 100 * 1024 * 1024) + +``max_bytes`` may be a callable ``(app) -> int`` for a limit that is itself a +runtime setting; it is evaluated per request. The first matching rule in +registration order wins, and its value *replaces* the global ceiling. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import Any + +from simple_module_core.public_routes import PublicRoute + +__all__ = ["BodyLimitRegistry"] + +MaxBytes = int | Callable[[Any], int] + + +class BodyLimitRegistry: + def __init__(self) -> None: + self._rules: list[tuple[PublicRoute, MaxBytes]] = [] + + def add( + self, + pattern: str, + max_bytes: MaxBytes, + *, + methods: Iterable[str] | None = None, + kind: str = "prefix", + ) -> None: + """Register a ceiling. ``kind`` is prefix / exact / suffix / regex.""" + if isinstance(max_bytes, int) and max_bytes < 0: + raise ValueError("max_bytes must be >= 0 (0 means unlimited)") + self._rules.append((PublicRoute(pattern, methods=methods, kind=kind), max_bytes)) + + def add_prefix( + self, prefix: str, max_bytes: MaxBytes, *, methods: Iterable[str] | None = None + ) -> None: + self.add(prefix, max_bytes, methods=methods, kind="prefix") + + def add_exact( + self, path: str, max_bytes: MaxBytes, *, methods: Iterable[str] | None = None + ) -> None: + self.add(path, max_bytes, methods=methods, kind="exact") + + def add_regex( + self, pattern: str, max_bytes: MaxBytes, *, methods: Iterable[str] | None = None + ) -> None: + self.add(pattern, max_bytes, methods=methods, kind="regex") + + def limit_for(self, method: str, path: str, app: Any = None) -> int | None: + """The override for this request, or ``None`` to use the global ceiling. + + ``0`` means "unlimited for this route". + """ + for route, max_bytes in self._rules: + if route.matches(method, path): + return max_bytes if isinstance(max_bytes, int) else int(max_bytes(app)) + return None + + def __len__(self) -> int: + return len(self._rules) diff --git a/framework/core/simple_module_core/diagnostics/_module.py b/framework/core/simple_module_core/diagnostics/_module.py index 4c53062b..68c5f566 100644 --- a/framework/core/simple_module_core/diagnostics/_module.py +++ b/framework/core/simple_module_core/diagnostics/_module.py @@ -102,6 +102,7 @@ def _check_empty_modules(self, modules: list[ModuleBase]) -> list[Diagnostic]: "register_middleware", "register_health_checks", "register_public_routes", + "register_body_limits", "register_csp_sources", "register_exception_handlers", "register_settings", diff --git a/framework/core/simple_module_core/module.py b/framework/core/simple_module_core/module.py index 2ba6096b..4ae0b864 100644 --- a/framework/core/simple_module_core/module.py +++ b/framework/core/simple_module_core/module.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, FastAPI from simple_module_core.audit_links import AuditLinkRegistry + from simple_module_core.body_limits import BodyLimitRegistry from simple_module_core.csp import CspSourceRegistry from simple_module_core.design_packs import DesignPackRegistry from simple_module_core.events import EventBus @@ -160,15 +161,16 @@ def register_public_routes(self, registry: PublicRouteRegistry) -> None: def register_public_routes(self, registry): registry.add_prefix("/api/gis/stac") - registry.add_regex( - r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"} - ) + registry.add_regex(r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"}) Rules are method-aware, so a GET read route nested under a prefix that also carries POST/PATCH mutations can be exempted without opening the mutations. Called once at boot, in dependency order. """ + def register_body_limits(self, registry: BodyLimitRegistry) -> None: + """Per-route request-body ceilings; see docs/framework/request-guards.md.""" + def register_csp_sources(self, registry: CspSourceRegistry) -> None: """Declare external origins this module's frontend loads assets from. diff --git a/framework/core/simple_module_core/public_routes.py b/framework/core/simple_module_core/public_routes.py index 6b9379e6..0555bcda 100644 --- a/framework/core/simple_module_core/public_routes.py +++ b/framework/core/simple_module_core/public_routes.py @@ -19,6 +19,8 @@ import re from collections.abc import Iterable +from simple_module_core.rate_limit import parse_rate + _MatchKind = str # one of: "prefix" | "exact" | "suffix" | "regex" _VALID_KINDS = ("prefix", "exact", "suffix", "regex") @@ -34,9 +36,12 @@ class PublicRoute: kind: How ``pattern`` is interpreted — ``"prefix"`` (default, matches any path that starts with it), ``"exact"``, ``"suffix"``, or ``"regex"`` (anchored at the start of the path via ``re.match``). + rate: Optional per-rule rate limit for anonymous callers, e.g. + ``"60/minute"``. ``None`` uses the host default; ``"off"`` exempts + the rule from rate limiting. Validated at construction. """ - __slots__ = ("_regex", "kind", "methods", "pattern") + __slots__ = ("_regex", "kind", "methods", "pattern", "rate", "rate_spec") def __init__( self, @@ -44,6 +49,7 @@ def __init__( *, methods: Iterable[str] | None = None, kind: _MatchKind = "prefix", + rate: str | None = None, ) -> None: if kind not in _VALID_KINDS: raise ValueError(f"Unknown match kind {kind!r}; expected one of {_VALID_KINDS}") @@ -52,6 +58,9 @@ def __init__( None if methods is None else frozenset(m.upper() for m in methods) ) self.kind = kind + # Parsed here so a bad value fails at registration, not on the first request. + self.rate_spec = parse_rate(rate) + self.rate = rate self._regex = re.compile(pattern) if kind == "regex" else None def matches(self, method: str, path: str) -> bool: @@ -89,6 +98,7 @@ def add( *, methods: Iterable[str] | None = None, kind: _MatchKind = "prefix", + rate: str | None = None, ) -> None: """Register a rule — either a prebuilt :class:`PublicRoute` or a pattern. @@ -98,28 +108,52 @@ def add( if isinstance(route, PublicRoute): self._routes.append(route) else: - self._routes.append(PublicRoute(route, methods=methods, kind=kind)) + self._routes.append(PublicRoute(route, methods=methods, kind=kind, rate=rate)) - def add_prefix(self, prefix: str, *, methods: Iterable[str] | None = None) -> None: + def add_prefix( + self, prefix: str, *, methods: Iterable[str] | None = None, rate: str | None = None + ) -> None: """Exempt any path starting with *prefix*.""" - self._routes.append(PublicRoute(prefix, methods=methods, kind="prefix")) + self._routes.append(PublicRoute(prefix, methods=methods, kind="prefix", rate=rate)) - def add_exact(self, path: str, *, methods: Iterable[str] | None = None) -> None: + def add_exact( + self, path: str, *, methods: Iterable[str] | None = None, rate: str | None = None + ) -> None: """Exempt exactly *path*.""" - self._routes.append(PublicRoute(path, methods=methods, kind="exact")) + self._routes.append(PublicRoute(path, methods=methods, kind="exact", rate=rate)) - def add_suffix(self, suffix: str, *, methods: Iterable[str] | None = None) -> None: + def add_suffix( + self, suffix: str, *, methods: Iterable[str] | None = None, rate: str | None = None + ) -> None: """Exempt any path ending with *suffix*.""" - self._routes.append(PublicRoute(suffix, methods=methods, kind="suffix")) + self._routes.append(PublicRoute(suffix, methods=methods, kind="suffix", rate=rate)) - def add_regex(self, pattern: str, *, methods: Iterable[str] | None = None) -> None: + def add_regex( + self, pattern: str, *, methods: Iterable[str] | None = None, rate: str | None = None + ) -> None: """Exempt any path whose start matches *pattern* (``re.match`` semantics).""" - self._routes.append(PublicRoute(pattern, methods=methods, kind="regex")) + self._routes.append(PublicRoute(pattern, methods=methods, kind="regex", rate=rate)) def matches(self, method: str, path: str) -> bool: """Return ``True`` if any registered rule exempts *method* + *path*.""" return any(route.matches(method, path) for route in self._routes) + def match(self, method: str, path: str) -> PublicRoute | None: + """The rule that governs *method* + *path*, if any. + + A rule carrying its own ``rate=`` beats an overlapping one without, so a + broad rule registered earlier cannot silently shadow a narrower rate + override; among equals the first registered wins. + """ + first = None + for route in self._routes: + if not route.matches(method, path): + continue + if route.rate is not None: + return route + first = first or route + return first + @property def routes(self) -> list[PublicRoute]: """All registered rules (a copy — mutating it doesn't affect the registry).""" diff --git a/framework/core/simple_module_core/rate_limit.py b/framework/core/simple_module_core/rate_limit.py new file mode 100644 index 00000000..1430ff83 --- /dev/null +++ b/framework/core/simple_module_core/rate_limit.py @@ -0,0 +1,126 @@ +"""Rate-limit primitives shared by the host's request guards. + +Two pieces, both free of Starlette/Redis so any layer can use them: + +* :func:`parse_rate` turns ``"120/minute"`` into a :class:`RateSpec`. +* :class:`InProcessWindowStore` is the fixed-window counter the host falls back + to when no Redis is configured. Its counters live in one worker process, so a + multi-worker deployment multiplies the effective limit by the worker count. + +The Redis-backed store lives in ``simple_module_hosting`` (it owns the +connection); both implement :class:`WindowStore`. +""" + +from __future__ import annotations + +import math +import re +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Protocol + +__all__ = [ + "InProcessWindowStore", + "RateLimitError", + "RateSpec", + "WindowResult", + "WindowStore", + "parse_rate", +] + +_PERIODS = { + "second": 1, + "sec": 1, + "s": 1, + "minute": 60, + "min": 60, + "m": 60, + "hour": 3600, + "h": 3600, + "day": 86400, + "d": 86400, +} +_RATE_RE = re.compile(r"^\s*(\d+)\s*/\s*(?:(\d+)\s*)?([a-z]+?)s?\s*$", re.IGNORECASE) +_OFF_WORDS = frozenset({"", "off", "none", "false", "disabled", "0"}) + + +class RateLimitError(ValueError): + """A rate string that is not ``/``.""" + + +@dataclass(frozen=True, slots=True) +class RateSpec: + """``limit`` requests per ``period`` seconds (fixed window).""" + + limit: int + period: int + + def __str__(self) -> str: + return f"{self.limit}/{self.period}s" + + +def parse_rate(value: str | None) -> RateSpec | None: + """Parse ``"120/minute"`` / ``"5/10s"`` / ``"1000/hour"``. + + Returns ``None`` for a disabled limit (blank, ``off``, ``none``, ``0``). + Raises :class:`RateLimitError` for anything else that does not parse, so a + typo fails loudly at boot instead of silently disabling protection. + """ + if value is None or value.strip().lower() in _OFF_WORDS: + return None + match = _RATE_RE.match(value) + if match is None: + raise RateLimitError(f"invalid rate {value!r}; expected '/'") + count, multiple, unit = int(match.group(1)), match.group(2), match.group(3).lower() + seconds = _PERIODS.get(unit) + if seconds is None: + raise RateLimitError(f"invalid rate {value!r}: unknown period {unit!r}") + if count <= 0: + return None + return RateSpec(limit=count, period=seconds * (int(multiple) if multiple else 1)) + + +@dataclass(frozen=True, slots=True) +class WindowResult: + allowed: bool + #: Seconds until the current window ends (the ``Retry-After`` value). + retry_after: int + count: int + + +class WindowStore(Protocol): + async def hit(self, key: str, spec: RateSpec) -> WindowResult: + """Count one request against *key* and report whether it is within budget.""" + ... + + +class InProcessWindowStore: + """Per-process fixed-window counters. Not shared between workers.""" + + _MAX_KEYS = 50_000 + + def __init__(self, *, clock: Callable[[], float] = time.time) -> None: + self._clock = clock + self._windows: dict[str, tuple[int, int]] = {} # key -> (window_end_ms, count) + + async def hit(self, key: str, spec: RateSpec) -> WindowResult: + now = self._clock() + entry = self._windows.get(key) + if entry is None or entry[0] <= now * 1000: + if len(self._windows) >= self._MAX_KEYS: + self._sweep(now) + entry = (int(now * 1000) + spec.period * 1000, 0) + end_ms, count = entry + count += 1 + self._windows[key] = (end_ms, count) + retry = max(1, math.ceil(end_ms / 1000 - now)) + return WindowResult(allowed=count <= spec.limit, retry_after=retry, count=count) + + def _sweep(self, now: float) -> None: + now_ms = now * 1000 + self._windows = {k: v for k, v in self._windows.items() if v[0] > now_ms} + if len(self._windows) >= self._MAX_KEYS: + # Everything is live: shed the oldest half rather than grow unbounded. + keep = sorted(self._windows.items(), key=lambda kv: kv[1][0], reverse=True) + self._windows = dict(keep[: self._MAX_KEYS // 2]) diff --git a/framework/core/tests/test_public_routes_rate_precedence.py b/framework/core/tests/test_public_routes_rate_precedence.py new file mode 100644 index 00000000..6e6732a8 --- /dev/null +++ b/framework/core/tests/test_public_routes_rate_precedence.py @@ -0,0 +1,23 @@ +"""A rate-bearing public rule is not shadowed by an earlier overlapping rule (GH #347).""" + +from __future__ import annotations + +from simple_module_core.public_routes import PublicRouteRegistry + + +def test_rate_bearing_rule_beats_earlier_overlapping_rule_without_one() -> None: + registry = PublicRouteRegistry() + registry.add_prefix("/api/pub/") + registry.add_exact("/api/pub/login", rate="5/minute") + login = registry.match("GET", "/api/pub/login") + other = registry.match("GET", "/api/pub/other") + assert login is not None and login.rate == "5/minute" + assert other is not None and other.rate is None + + +def test_first_registered_wins_among_equals() -> None: + registry = PublicRouteRegistry() + registry.add_prefix("/a/", rate="1/minute") + registry.add_prefix("/a/b", rate="9/minute") + rule = registry.match("GET", "/a/b") + assert rule is not None and rule.rate == "1/minute" diff --git a/framework/hosting/pyproject.toml b/framework/hosting/pyproject.toml index cb99cc6f..e76cd7e7 100644 --- a/framework/hosting/pyproject.toml +++ b/framework/hosting/pyproject.toml @@ -37,6 +37,10 @@ dependencies = [ "uvicorn[standard]>=0.34", ] +[project.optional-dependencies] +# Shared rate-limiter store (SM_REDIS_URL). Without it the limiter counts per worker. +redis = ["redis>=5"] + [project.scripts] sm-host = "simple_module_hosting.host_cli:app" diff --git a/framework/hosting/simple_module_hosting/_body_limit.py b/framework/hosting/simple_module_hosting/_body_limit.py new file mode 100644 index 00000000..ee266eb2 --- /dev/null +++ b/framework/hosting/simple_module_hosting/_body_limit.py @@ -0,0 +1,191 @@ +"""Request-body size guard (GH #345). + +Pure ASGI. Refuses an oversized body *before* a route reads it, so the limit +protects the process and not just the database: + +* ``Content-Length`` over the ceiling is refused outright, body unread. +* No (or a lying) ``Content-Length`` — chunked transfer — is caught by counting + the bytes the app actually pulls through ``receive`` and aborting once the + ceiling is crossed. + +The ceiling is the global ``max_request_body_bytes`` unless a module declared a +per-path override with ``register_body_limits``. +""" + +from __future__ import annotations + +import logging + +from simple_module_core.body_limits import BodyLimitRegistry +from starlette.datastructures import Headers +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from simple_module_hosting._error_handlers import _wants_json +from simple_module_hosting._request_guard_responses import send_guard_response + +logger = logging.getLogger("simple_module.request_guard") + +_MESSAGE = "Request body is too large." + + +class _BodyTooLargeError(Exception): + pass + + +class BodyLimitMiddleware: + def __init__( + self, + app: ASGIApp, + *, + max_bytes: int, + registry: BodyLimitRegistry | None = None, + ) -> None: + self.app = app + self.max_bytes = max_bytes + self.registry = registry + + def _ceiling(self, scope: Scope) -> int: + override = None + if self.registry is not None: + override = self.registry.limit_for(scope["method"], scope["path"], scope.get("app")) + return self.max_bytes if override is None else override + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + limit = self._ceiling(scope) + if limit <= 0: + await self.app(scope, receive, send) + return + + declared = Headers(scope=scope).get("content-length") + too_big = ( + declared is not None + and declared.isascii() + and declared.isdigit() + and int(declared) > limit + ) + # A browser-shaped request is answered with the Inertia error page, which + # needs the session / locale / shared props the layers *inside* this one + # set up. So it is not refused here: the body read raises an + # ``HTTPException(413)`` that the app's own handler renders in full. + # (FastAPI re-raises HTTPException from body parsing untouched.) API + # callers are refused right here, before anything runs. + if not self._wants_json(scope): + await self._handle_page(scope, receive, send, limit, too_big) + return + if too_big: + self._log(scope, limit, int(declared)) + await self._refuse(scope, receive, send, limit) + return + + seen = 0 + exceeded = False + refused = False + started = False + + async def counting_receive() -> Message: + nonlocal seen, exceeded + message = await receive() + if message["type"] == "http.request": + seen += len(message.get("body", b"")) + if seen > limit: + exceeded = True + raise _BodyTooLargeError + return message + + async def guarded_send(message: Message) -> None: + nonlocal refused, started + if exceeded and not started: + # The app is answering a body we cut off (often with a 400 from + # its own parser). Swallow it; the 413 below is the real answer. + if not refused: + refused = True + self._log(scope, limit, seen) + await self._refuse(scope, receive, send, limit) + return + # Once the app's own response has started it is passed through whole: + # swallowing the rest would leave the client hanging on a half-sent + # response. (Any exception it raises for the cut-off body is re-raised + # below, which aborts the connection instead.) + if message["type"] == "http.response.start": + started = True + await send(message) + + try: + await self.app(scope, counting_receive, guarded_send) + except Exception: + if not exceeded or started: + if exceeded: + self._log(scope, limit, seen) + raise + if exceeded and not refused and not started: + self._log(scope, limit, seen) + await self._refuse(scope, receive, send, limit) + + @staticmethod + def _wants_json(scope: Scope) -> bool: + return _wants_json(Request(scope)) + + async def _handle_page( + self, scope: Scope, receive: Receive, send: Send, limit: int, too_big: bool + ) -> None: + seen = 0 + + async def receive_or_413() -> Message: + nonlocal seen + if too_big: + self._log(scope, limit, seen) + raise HTTPException(status_code=413, detail=_MESSAGE) + message = await receive() + if message["type"] == "http.request": + seen += len(message.get("body", b"")) + if seen > limit: + self._log(scope, limit, seen) + raise HTTPException(status_code=413, detail=_MESSAGE) + return message + + started = False + + async def tracking_send(message: Message) -> None: + nonlocal started + if message["type"] == "http.response.start": + started = True + await send(message) + + try: + await self.app(scope, receive_or_413, tracking_send) + except HTTPException as exc: + # Raised by a middleware that reads the body *outside* the router's + # exception handling (e.g. a form gate). Without this it would + # surface as a bare 500 from ServerErrorMiddleware. + if exc.status_code != 413 or started: + raise + await self._refuse(scope, receive, send, limit) + + @staticmethod + def _log(scope: Scope, limit: int, size: int) -> None: + cid = scope.get("state", {}).get("correlation_id", "") + logger.warning( + "Request body over limit: %s %s (%d > %d bytes) correlation_id=%s", + scope["method"], + scope["path"], + size, + limit, + cid, + ) + + @staticmethod + async def _refuse(scope: Scope, receive: Receive, send: Send, limit: int) -> None: + await send_guard_response( + scope, + receive, + send, + 413, + _MESSAGE, + headers={"Connection": "close"}, + extra={"max_bytes": limit}, + ) diff --git a/framework/hosting/simple_module_hosting/_error_handlers.py b/framework/hosting/simple_module_hosting/_error_handlers.py index cfd505d8..56f17260 100644 --- a/framework/hosting/simple_module_hosting/_error_handlers.py +++ b/framework/hosting/simple_module_hosting/_error_handlers.py @@ -26,7 +26,7 @@ logger = logging.getLogger(__name__) -_INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 419, 422, 429, 500, 503}) +_INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 413, 419, 422, 429, 500, 503}) # Statuses whose remedy is "sign in", so the page offers that as its primary # action rather than sending the visitor to the landing page. diff --git a/framework/hosting/simple_module_hosting/_phase_helpers.py b/framework/hosting/simple_module_hosting/_phase_helpers.py index e162be18..fb66a47e 100644 --- a/framework/hosting/simple_module_hosting/_phase_helpers.py +++ b/framework/hosting/simple_module_hosting/_phase_helpers.py @@ -14,7 +14,6 @@ from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from fastapi.staticfiles import StaticFiles -from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel from simple_module_core.exceptions import NotFoundError from simple_module_db import CommitBeforeResponseMiddleware from simple_module_inertia import ( @@ -25,16 +24,16 @@ from starlette.middleware.gzip import GZipMiddleware from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware +from simple_module_hosting._body_limit import BodyLimitMiddleware from simple_module_hosting._error_handlers import ( http_exception_handler, not_found_error_handler, request_validation_error_handler, unhandled_exception_handler, ) -from simple_module_hosting._host_services import _HostServices from simple_module_hosting._inertia_cache import InertiaCacheMiddleware from simple_module_hosting._module_routes import wire_module_routes -from simple_module_hosting.host_settings import HostSettings +from simple_module_hosting._rate_limit import RateLimitMiddleware, build_store from simple_module_hosting.i18n_middleware import LocaleMiddleware from simple_module_hosting.maintenance import MaintenanceMiddleware from simple_module_hosting.middleware import ( @@ -105,8 +104,9 @@ def install_middleware( Order matters: last added = first executed. Execution order: (ProxyHeaders, if trusted_proxy) → CorrelationId → RequestLogging - → Security → Session → [module] → (Tenant, if multi_tenant) → Locale - → Inertia → InertiaCache → Setup → Maintenance → CommitBeforeResponse. + → BodyLimit → GZip → Security → Session → [module] → (Tenant, if + multi_tenant) → Locale → Inertia → InertiaCache → RateLimit → Setup + → Maintenance → CommitBeforeResponse. """ # Added first, so it is innermost and its send-wrapper is the first to see # the response: the request's DB work commits before any byte reaches the @@ -127,6 +127,15 @@ def install_middleware( # reason Maintenance is — this short-circuits, and the redirect must not # be stored by any cache. app.add_middleware(SetupMiddleware) + # Added before InertiaCache so it executes just after it: auth has run (it + # can tell anonymous from signed-in), locale/shared props are in place for + # the error page, and a 429 is still marked non-cacheable. + app.add_middleware( + RateLimitMiddleware, + public_rate=settings.rate_limit_public, + authenticated_rate=settings.rate_limit_authenticated, + store=build_store(settings.redis_url), + ) # Paired with InertiaLayoutDataMiddleware below, which is what puts this # user's auth, permissions and menus into every Inertia payload: this one # makes sure the payload that results is never stored where a page request @@ -168,6 +177,13 @@ def install_middleware( # JS bundle compresses about 3x. Uncompressed assets dominated cold page # load, several times larger than anything on the server request path. app.add_middleware(GZipMiddleware, minimum_size=COMPRESSION_MIN_BYTES) + # Right inside RequestLogging (so a 413 is logged) and outside GZip and the + # whole module tier: an oversized body is refused before anything reads it. + app.add_middleware( + BodyLimitMiddleware, + max_bytes=settings.max_request_body_bytes, + registry=getattr(app.state, "body_limits", None), + ) app.add_middleware(RequestLoggingMiddleware) app.add_middleware(CorrelationIdMiddleware) # Outermost: rewrite scheme/client from X-Forwarded-* before anything else @@ -230,65 +246,4 @@ def mount_module_static_dirs(app: FastAPI, modules: list) -> None: ) -def register_host_settings(app: FastAPI) -> None: - """Register host-level settings under ``package="host"`` (DB-backed). - - The Settings module must already have run ``register_settings`` — topo - order puts it early, since its ``meta.depends_on`` is empty. When the - Settings module isn't enabled there's no registry to register against, so - this skips quietly. - - ``settings.registration`` is resolved via importlib rather than a plain - ``from settings.registration import ...``: the SM009 coupling check is - AST-based and forbids any static import of a plugin package name from - within ``framework/*``. Dynamic resolution keeps the framework AST - plugin-free while still hitting the real helper at runtime. - """ - if not hasattr(app.state, "settings"): - return - - import importlib - - register_module_settings = importlib.import_module( - "settings.registration" - ).register_module_settings - - register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s)) - - -def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]: - """SM012: warn if a module overrides register_settings but added nothing to app.state. - - Must run after Phase 4 (register_settings) and therefore can't join the - Phase 2 diagnostics pass; returning a list lets the caller route it through - the same ``print_diagnostics`` sink. - """ - diagnostics: list[Diagnostic] = [] - for mod in modules: - cls = type(mod) - if "register_settings" not in cls.__dict__: - continue - # Match the convention actually used by modules: `app.state.` - # (snake_case package name, e.g. `background_tasks`), which aligns - # with Settings-module autodiscovery in `settings._module_settings`. - package = cls.__module__.split(".", 1)[0] - candidates = (package, mod.meta.name.lower()) - if any(hasattr(app.state, c) for c in candidates): - continue - mod_prefix = package - diagnostics.append( - Diagnostic( - level=DiagnosticLevel.WARNING, - code="SM012", - message="register_settings() was overridden but added nothing to app.state", - module_name=mod.meta.name, - suggestion=( - f"Store your module state on app.state " - f"(e.g., app.state.{mod_prefix} = {mod.meta.name}Services(...))" - ), - ) - ) - return diagnostics - - __all__ = ["wire_module_routes"] diff --git a/framework/hosting/simple_module_hosting/_preapp_config.py b/framework/hosting/simple_module_hosting/_preapp_config.py index 46ee2f2b..cf2bf5e2 100644 --- a/framework/hosting/simple_module_hosting/_preapp_config.py +++ b/framework/hosting/simple_module_hosting/_preapp_config.py @@ -48,7 +48,7 @@ def _settings_table() -> tuple[str, str, str] | None: """Resolve the settings module's table name and system-scope markers. Resolved through ``importlib`` rather than a static import for the same - reason as ``_phase_helpers.register_host_settings``: the SM009 coupling + reason as ``_settings_registration.register_host_settings``: the SM009 coupling check is AST-based and forbids ``framework/*`` from naming a plugin package. Returns ``None`` when the settings module isn't installed, which makes the whole pre-app read a no-op rather than an error. diff --git a/framework/hosting/simple_module_hosting/_rate_limit.py b/framework/hosting/simple_module_hosting/_rate_limit.py new file mode 100644 index 00000000..a59fafc0 --- /dev/null +++ b/framework/hosting/simple_module_hosting/_rate_limit.py @@ -0,0 +1,187 @@ +"""Shared rate limiter for anonymous traffic to public routes (GH #347). + +Pure ASGI, keyed on client IP. Sits *after* the auth middleware so it can tell +anonymous from signed-in requests, and after ``ProxyHeaders`` so +``SM_TRUSTED_PROXY`` is honoured (``scope["client"]`` is already rewritten). + +Policy: + +* a request is limited by ``rate_limit_public`` only when the auth middleware judged + it anonymous-allowed (``scope["state"]["auth_public"]``: framework defaults, the + registry and the provider legacy paths; the registry alone if no auth provider + is installed) **and** nobody is signed in. Health/static are never limited; +* a matching rule's own ``rate=`` replaces that default for the rule; +* ``rate_limit_authenticated`` (off by default) limits signed-in traffic; +* every other request is untouched. + +Counting goes to Redis (``SM_REDIS_URL``, atomic Lua INCR + PEXPIRE) so all +workers share one budget; with no Redis, or while Redis is failing, it counts in +per-process counters — the limiter must never take requests down. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from simple_module_core.rate_limit import ( + InProcessWindowStore, + RateSpec, + WindowResult, + WindowStore, + parse_rate, +) +from starlette.types import ASGIApp, Receive, Scope, Send + +from simple_module_hosting._request_guard_responses import send_guard_response + +logger = logging.getLogger("simple_module.request_guard") + +_MESSAGE = "Too many requests. Please slow down and try again shortly." +_KEY_PREFIX = "sm:rl:" +_OPERATIONAL_PREFIXES = ("/health", "/static/") +# Re-warn about a dead Redis at most this often, not once per request. +_WARN_EVERY = 60.0 +# After a Redis error, skip Redis entirely for this long. Without it a blackholed +# Redis costs every anonymous request the full socket timeout. +_BACKOFF = 5.0 + +_LUA = """ +local c = redis.call('INCR', KEYS[1]) +local t = redis.call('PTTL', KEYS[1]) +-- t < 0 heals a counter that somehow lost its TTL, which would block that IP forever. +if c == 1 or t < 0 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) + t = tonumber(ARGV[1]) +end +return {c, t} +""" + + +class RedisWindowStore: + """Fixed-window counters in Redis; shared across workers and processes. + + Never takes requests down: on a Redis error the hit is counted in a per-process + fallback store instead (bounded, weaker than the shared budget but not "no + limit"), and Redis is re-probed after ``_BACKOFF`` seconds, so recovery is + automatic and nothing latches. + """ + + def __init__(self, client: Any, *, clock=time.monotonic) -> None: + self._client = client + self._clock = clock + self._last_warn = float("-inf") + self._down_until = float("-inf") + self._fallback = InProcessWindowStore() + + @classmethod + def from_url(cls, url: str) -> RedisWindowStore: + import redis.asyncio as aioredis + + # Short timeouts: this is on the request path, and redis-py defaults to + # none, so a Redis that accepts connections but stalls would hang us. + client = aioredis.from_url(url, socket_connect_timeout=0.5, socket_timeout=0.5) + return cls(client) + + async def hit(self, key: str, spec: RateSpec) -> WindowResult: + if self._clock() < self._down_until: + return await self._fallback.hit(key, spec) + try: + count, pttl = await self._client.eval(_LUA, 1, _KEY_PREFIX + key, spec.period * 1000) + except Exception as exc: + now = self._clock() + self._down_until = now + _BACKOFF + if now - self._last_warn >= _WARN_EVERY: + self._last_warn = now + logger.warning( + "Rate limiter Redis unavailable (%s); using per-worker counters", exc + ) + return await self._fallback.hit(key, spec) + retry = max(1, -(-int(pttl) // 1000)) if int(pttl) > 0 else spec.period + return WindowResult(allowed=int(count) <= spec.limit, retry_after=retry, count=int(count)) + + +def build_store(redis_url: str | None) -> WindowStore: + """Redis when configured and importable, else per-process counters.""" + if redis_url: + try: + return RedisWindowStore.from_url(redis_url) + except Exception as exc: # missing redis package, malformed URL + logger.warning("Rate limiter cannot use Redis (%s); using per-worker counters", exc) + return InProcessWindowStore() + + +class RateLimitMiddleware: + def __init__( + self, + app: ASGIApp, + *, + public_rate: str | None, + authenticated_rate: str | None = None, + store: WindowStore | None = None, + ) -> None: + self.app = app + self.public_rate = parse_rate(public_rate) + self.authenticated_rate = parse_rate(authenticated_rate) + self.store = store if store is not None else InProcessWindowStore() + + def _policy(self, scope: Scope) -> tuple[str, RateSpec] | None: + authed = scope.get("state", {}).get("user") is not None + if authed: + if self.authenticated_rate is None: + return None + return "auth", self.authenticated_rate + registry = getattr(scope["app"].state, "public_routes", None) + rule = registry.match(scope["method"], scope["path"]) if registry is not None else None + flag = scope.get("state", {}).get("auth_public") + if flag is None: # no auth provider recorded a decision: registry is the truth + flag = rule is not None + if not flag: + return None + if rule is None and scope["path"].startswith(_OPERATIONAL_PREFIXES): + return None # health probes and static assets are not an API surface + if rule is not None and rule.rate is not None: + spec = rule.rate_spec + # Methods are part of the bucket: two rules with one pattern but different + # verbs/rates must not share a counter (Redis fixes the window length on + # the first hit, so a shared key would apply the wrong period). + methods = ",".join(sorted(rule.methods)) if rule.methods else "*" + bucket = f"rule:{rule.kind}:{methods}:{rule.pattern}" + return (bucket, spec) if spec is not None else None + if self.public_rate is None: + return None + return "public", self.public_rate + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + policy = self._policy(scope) + if policy is None: + await self.app(scope, receive, send) + return + bucket, spec = policy + client = scope.get("client") + ip = client[0] if client else "unknown" + result = await self.store.hit(f"{bucket}:{ip}", spec) + if result.allowed: + await self.app(scope, receive, send) + return + logger.warning( + "Rate limit exceeded: %s %s ip=%s bucket=%s limit=%s", + scope["method"], + scope["path"], + ip, + bucket, + spec, + ) + await send_guard_response( + scope, + receive, + send, + 429, + _MESSAGE, + headers={"Retry-After": str(result.retry_after)}, + extra={"retry_after": result.retry_after}, + ) diff --git a/framework/hosting/simple_module_hosting/_registrations.py b/framework/hosting/simple_module_hosting/_registrations.py index 8639ea1b..5c584e1f 100644 --- a/framework/hosting/simple_module_hosting/_registrations.py +++ b/framework/hosting/simple_module_hosting/_registrations.py @@ -29,6 +29,7 @@ def run_module_registrations( design_pack_registry, audit_link_registry, csp_registry, + body_limit_registry=None, ) -> None: """Invoke each module's registration hooks, in dependency order. @@ -47,6 +48,8 @@ def run_module_registrations( health_registry.set_owner(mod.meta.name) mod.register_health_checks(health_registry) mod.register_public_routes(public_route_registry) + if body_limit_registry is not None: + mod.register_body_limits(body_limit_registry) setup_registry.set_owner(mod.meta.name) mod.register_setup_steps(setup_registry) # csp before design packs — the documented lifecycle order diff --git a/framework/hosting/simple_module_hosting/_request_guard_responses.py b/framework/hosting/simple_module_hosting/_request_guard_responses.py new file mode 100644 index 00000000..d6ea641b --- /dev/null +++ b/framework/hosting/simple_module_hosting/_request_guard_responses.py @@ -0,0 +1,50 @@ +"""Shared 413 / 429 response building for the pipeline-level request guards. + +The guards run as raw ASGI middleware, outside Starlette's exception handling, +so they cannot raise ``HTTPException``. They build the same two shapes the +exception handlers do: JSON for API callers, the Inertia error page otherwise +(negotiated by ``_wants_json``). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.types import Receive, Scope, Send + +from simple_module_hosting._error_handlers import _wants_json, render_error_page + + +async def guard_response( + scope: Scope, + status_code: int, + message: str, + *, + headers: Mapping[str, str] | None = None, + extra: Mapping[str, Any] | None = None, +) -> Response: + request = Request(scope) + if _wants_json(request): + return JSONResponse( + status_code=status_code, + content={"detail": message, **(extra or {})}, + headers=dict(headers) if headers else None, + ) + return await render_error_page(request, status_code, message, headers) + + +async def send_guard_response( + scope: Scope, + receive: Receive, + send: Send, + status_code: int, + message: str, + *, + headers: Mapping[str, str] | None = None, + extra: Mapping[str, Any] | None = None, +) -> None: + response = await guard_response(scope, status_code, message, headers=headers, extra=extra) + await response(scope, receive, send) diff --git a/framework/hosting/simple_module_hosting/_settings_registration.py b/framework/hosting/simple_module_hosting/_settings_registration.py new file mode 100644 index 00000000..57a3094b --- /dev/null +++ b/framework/hosting/simple_module_hosting/_settings_registration.py @@ -0,0 +1,70 @@ +"""Host-settings registration and its SM012 check (split from ``_phase_helpers``).""" + +from __future__ import annotations + +from fastapi import FastAPI +from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel + +from simple_module_hosting._host_services import _HostServices +from simple_module_hosting.host_settings import HostSettings + + +def register_host_settings(app: FastAPI) -> None: + """Register host-level settings under ``package="host"`` (DB-backed). + + The Settings module must already have run ``register_settings`` — topo + order puts it early, since its ``meta.depends_on`` is empty. When the + Settings module isn't enabled there's no registry to register against, so + this skips quietly. + + ``settings.registration`` is resolved via importlib rather than a plain + ``from settings.registration import ...``: the SM009 coupling check is + AST-based and forbids any static import of a plugin package name from + within ``framework/*``. Dynamic resolution keeps the framework AST + plugin-free while still hitting the real helper at runtime. + """ + if not hasattr(app.state, "settings"): + return + + import importlib + + register_module_settings = importlib.import_module( + "settings.registration" + ).register_module_settings + + register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s)) + + +def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]: + """SM012: warn if a module overrides register_settings but added nothing to app.state. + + Must run after Phase 4 (register_settings) and therefore can't join the + Phase 2 diagnostics pass; returning a list lets the caller route it through + the same ``print_diagnostics`` sink. + """ + diagnostics: list[Diagnostic] = [] + for mod in modules: + cls = type(mod) + if "register_settings" not in cls.__dict__: + continue + # Match the convention actually used by modules: `app.state.` + # (snake_case package name, e.g. `background_tasks`), which aligns + # with Settings-module autodiscovery in `settings._module_settings`. + package = cls.__module__.split(".", 1)[0] + candidates = (package, mod.meta.name.lower()) + if any(hasattr(app.state, c) for c in candidates): + continue + mod_prefix = package + diagnostics.append( + Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM012", + message="register_settings() was overridden but added nothing to app.state", + module_name=mod.meta.name, + suggestion=( + f"Store your module state on app.state " + f"(e.g., app.state.{mod_prefix} = {mod.meta.name}Services(...))" + ), + ) + ) + return diagnostics diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py index fc0fb7a5..9fc7e74d 100644 --- a/framework/hosting/simple_module_hosting/app_builder.py +++ b/framework/hosting/simple_module_hosting/app_builder.py @@ -7,7 +7,7 @@ from pathlib import Path from fastapi import FastAPI -from simple_module_core import CspSourceRegistry +from simple_module_core import BodyLimitRegistry, CspSourceRegistry from simple_module_core.audit_links import AuditLinkRegistry from simple_module_core.design_packs import DesignPackRegistry from simple_module_core.diagnostics import print_diagnostics @@ -31,16 +31,18 @@ from simple_module_hosting._lifespan import build_lifespan from simple_module_hosting._phase_helpers import ( attach_public_routes, - check_settings_registration, install_middleware, mount_module_static_dirs, register_exception_handlers, - register_host_settings, wire_module_routes, ) from simple_module_hosting._preapp_config import merge_host_settings from simple_module_hosting._registrations import run_module_registrations from simple_module_hosting._secret_key import assert_not_placeholder +from simple_module_hosting._settings_registration import ( + check_settings_registration, + register_host_settings, +) from simple_module_hosting.health import router as health_router from simple_module_hosting.i18n_manifest import build_i18n_registry from simple_module_hosting.settings import Settings @@ -194,6 +196,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: # ── Phase 5: Module registrations ────────────────────── csp_registry = CspSourceRegistry() + body_limit_registry = BodyLimitRegistry() run_module_registrations( modules, app=app, @@ -208,7 +211,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: design_pack_registry=design_pack_registry, audit_link_registry=audit_link_registry, csp_registry=csp_registry, + body_limit_registry=body_limit_registry, ) + app.state.body_limits = body_limit_registry attach_public_routes(app, settings, public_route_registry) report_tenant_resolution(app, settings, diagnostics_state) # SM025 diff --git a/framework/hosting/simple_module_hosting/bootstrap_settings.py b/framework/hosting/simple_module_hosting/bootstrap_settings.py index 2fb970e0..8fc68a23 100644 --- a/framework/hosting/simple_module_hosting/bootstrap_settings.py +++ b/framework/hosting/simple_module_hosting/bootstrap_settings.py @@ -88,6 +88,11 @@ def __init__(self, **values: Any) -> None: modules_enabled: list[str] | None = None + redis_url: str | None = None + """Optional Redis for the shared rate limiter (``SM_REDIS_URL``). Env only — + it carries credentials and the limiter is built before the DB is read. Unset + means per-worker in-process counters.""" + @property def is_development(self) -> bool: return self.environment == "development" diff --git a/framework/hosting/simple_module_hosting/host_settings.py b/framework/hosting/simple_module_hosting/host_settings.py index 17f3dc4d..15c93b86 100644 --- a/framework/hosting/simple_module_hosting/host_settings.py +++ b/framework/hosting/simple_module_hosting/host_settings.py @@ -24,6 +24,9 @@ _RESTART_DB = {"requires_restart": True, "group": "Database"} +_RESTART_GUARD = {"requires_restart": True, "group": "Request guards"} + + class HostSettings(BaseSettings): """DB-backed host configuration — defaults live here, overrides in DB.""" @@ -96,11 +99,40 @@ class HostSettings(BaseSettings): ``register_public_routes`` hook, which is method-aware. """ + max_request_body_bytes: int = Field(default=10 * 1024 * 1024, json_schema_extra=_RESTART_GUARD) + """Global request-body ceiling in bytes; larger bodies get a 413 before any + route runs. ``0`` disables the guard. Modules override it per path with + ``register_body_limits``.""" + + rate_limit_public: str = Field(default="120/minute", json_schema_extra=_RESTART_GUARD) + """Per-client-IP limit for *anonymous* requests to routes the public-route + registry exempts from auth, e.g. ``"120/minute"``. Blank / ``off`` + disables. A public rule may override it with its own ``rate=``.""" + + rate_limit_authenticated: str = Field(default="", json_schema_extra=_RESTART_GUARD) + """Opt-in per-IP limit for signed-in traffic, same syntax. Blank (default) + leaves authenticated requests unlimited.""" + db_pool_size: int = Field(default=10, json_schema_extra=_RESTART_DB) db_max_overflow: int = Field(default=20, json_schema_extra=_RESTART_DB) db_pool_pre_ping: bool = Field(default=True, json_schema_extra=_RESTART_DB) db_pool_recycle: int = Field(default=1800, json_schema_extra=_RESTART_DB) + @field_validator("rate_limit_public", "rate_limit_authenticated", mode="after") + @classmethod + def _check_rate(cls, value: str) -> str: + from simple_module_core.rate_limit import parse_rate + + parse_rate(value) # raises RateLimitError (a ValueError) on a typo + return value.strip() + + @field_validator("max_request_body_bytes", mode="after") + @classmethod + def _check_body_bytes(cls, value: int) -> int: + if value < 0: + raise ValueError("max_request_body_bytes must be >= 0 (0 disables the guard)") + return value + @field_validator("auth_provider", mode="after") @classmethod def _normalize_auth_provider(cls, value: str) -> str: diff --git a/framework/hosting/tests/test_middleware_order.py b/framework/hosting/tests/test_middleware_order.py index 4beaef82..bc2635fe 100644 --- a/framework/hosting/tests/test_middleware_order.py +++ b/framework/hosting/tests/test_middleware_order.py @@ -2,9 +2,9 @@ CLAUDE.md spells out the pipeline: - CorrelationId → RequestLogging → GZip → Security → Session → + CorrelationId → RequestLogging → BodyLimit → GZip → Security → Session → → Tenant (opt-in) → Locale → InertiaLayoutData - → InertiaCache → Setup → Maintenance + → InertiaCache → RateLimit → Setup → Maintenance → CommitBeforeResponse → app Setup runs before Maintenance: an install that has never been set up has @@ -54,6 +54,12 @@ GZip sits inside the observability pair so those still see every request, but outside everything that produces a body — including the /static mount, which is where compression pays off most. +BodyLimit sits just inside RequestLogging (a 413 is still logged) and outside +GZip and every module, so an oversized body is refused before anything reads +it. RateLimit sits inside InertiaCache, after auth and locale/layout data: it +must know whether the caller is signed in, and its 429 page renders through +Inertia with the shared props. + Order matters and a swap is the kind of regression that breaks production without breaking any happy-path test. ``app.user_middleware`` lists middlewares in execution order (Starlette @@ -69,6 +75,7 @@ _EXPECTED_MULTI_TENANT = ( "CorrelationIdMiddleware", "RequestLoggingMiddleware", + "BodyLimitMiddleware", "GZipMiddleware", "SecurityHeadersMiddleware", "SessionMiddleware", @@ -79,6 +86,7 @@ "LocaleMiddleware", "InertiaLayoutDataMiddleware", "InertiaCacheMiddleware", + "RateLimitMiddleware", "SetupMiddleware", "MaintenanceMiddleware", "CommitBeforeResponseMiddleware", @@ -87,6 +95,7 @@ _EXPECTED_SINGLE_TENANT = ( "CorrelationIdMiddleware", "RequestLoggingMiddleware", + "BodyLimitMiddleware", "GZipMiddleware", "SecurityHeadersMiddleware", "SessionMiddleware", @@ -96,6 +105,7 @@ "LocaleMiddleware", "InertiaLayoutDataMiddleware", "InertiaCacheMiddleware", + "RateLimitMiddleware", "SetupMiddleware", "MaintenanceMiddleware", "CommitBeforeResponseMiddleware", diff --git a/framework/hosting/tests/test_request_guard_auth_parity.py b/framework/hosting/tests/test_request_guard_auth_parity.py new file mode 100644 index 00000000..a8af3afc --- /dev/null +++ b/framework/hosting/tests/test_request_guard_auth_parity.py @@ -0,0 +1,116 @@ +"""The rate limiter follows AuthMiddleware's own public/anonymous decision. + +AuthMiddleware records ``scope["state"]["auth_public"]`` (framework defaults + +public-route registry + provider legacy paths); the limiter keys on that flag so +the two can never disagree, including on path variants. +""" + +from __future__ import annotations + +import httpx +import pytest +from simple_module_core.public_routes import PublicRouteRegistry +from simple_module_hosting._rate_limit import RateLimitMiddleware +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route + + +async def _hits_429(app, path: str, ip: str, n: int = 125) -> bool: + transport = httpx.ASGITransport(app=app, client=(ip, 1)) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: + for _ in range(n): + if (await c.get(path, follow_redirects=False)).status_code == 429: + return True + return False + + +class TestRealAuthMiddleware: + @pytest.mark.parametrize( + "path", + [ + "/i18n/en.json", # framework-public prefix, not in the registry + "/users/login", # provider legacy public path, not in the registry + "/users/login/", # trailing slash + "/users/login?x=1", # query string + "//users/login", # double slash + "/%75sers/login", # percent-encoded + "/api/docs", + ], + ) + async def test_anonymous_public_paths_are_limited(self, app, path: str) -> None: + # Public per auth => limited. A variant auth does not treat as public is + # refused by auth before the limiter and so cannot be an unlimited bypass + # of a route that *is* public (the router would not match it either). + limited = await _hits_429(app, path, "9.9.9.1") + transport = httpx.ASGITransport(app=app, client=("9.9.9.9", 1)) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: + status = (await c.get(path, follow_redirects=False)).status_code + assert limited or status in (301, 302, 307, 308, 401, 404, 429), (path, status) + + @pytest.mark.parametrize("path", ["/i18n/en.json", "/users/login", "/api/docs"]) + async def test_canonical_public_paths_definitely_limited(self, app, path: str) -> None: + assert await _hits_429(app, path, "9.9.9.5") + + async def test_non_public_path_is_not_limited(self, app) -> None: + assert not await _hits_429(app, "/api/users/me", "9.9.9.2") + + async def test_static_and_health_never_limited(self, app) -> None: + assert not await _hits_429(app, "/static/nope.js", "9.9.9.3") + assert not await _hits_429(app, "/health", "9.9.9.4") + + +class _SetFlag: + def __init__(self, app, value) -> None: + self.app = app + self.value = value + + async def __call__(self, scope, receive, send) -> None: + scope.setdefault("state", {})["auth_public"] = self.value + await self.app(scope, receive, send) + + +def _app(flag, registry: PublicRouteRegistry) -> Starlette: + async def ok(request): + return JSONResponse({}) + + app = Starlette(routes=[Route("/p", ok)]) + app.state.public_routes = registry + app.add_middleware(RateLimitMiddleware, public_rate="1/minute") + if flag is not None: + app.add_middleware(_SetFlag, value=flag) + return app + + +class TestFlagContract: + async def test_flag_true_limits_even_without_registry_rule(self) -> None: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=_app(True, PublicRouteRegistry())), + base_url="http://t", + ) as c: + assert (await c.get("/p")).status_code == 200 + assert (await c.get("/p")).status_code == 429 + + async def test_flag_false_wins_over_registry_match(self) -> None: + reg = PublicRouteRegistry() + reg.add_prefix("/p") + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=_app(False, reg)), base_url="http://t" + ) as c: + for _ in range(4): + assert (await c.get("/p")).status_code == 200 + + async def test_no_flag_falls_back_to_registry(self) -> None: + reg = PublicRouteRegistry() + reg.add_prefix("/p") + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=_app(None, reg)), base_url="http://t" + ) as c: + assert (await c.get("/p")).status_code == 200 + assert (await c.get("/p")).status_code == 429 + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=_app(None, PublicRouteRegistry())), + base_url="http://t", + ) as c: + for _ in range(4): + assert (await c.get("/p")).status_code == 200 diff --git a/framework/hosting/tests/test_request_guard_body_limit.py b/framework/hosting/tests/test_request_guard_body_limit.py new file mode 100644 index 00000000..54559a27 --- /dev/null +++ b/framework/hosting/tests/test_request_guard_body_limit.py @@ -0,0 +1,181 @@ +"""Request-body size guard (GH #345): Content-Length, chunked, per-path, 413 shape.""" + +from __future__ import annotations + +import httpx +import pytest +from simple_module_core.body_limits import BodyLimitRegistry +from simple_module_hosting._body_limit import BodyLimitMiddleware +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +LIMIT = 1000 + + +def _build(registry: BodyLimitRegistry | None = None, *, limit: int = LIMIT) -> Starlette: + async def echo(request: Request): + body = await request.body() + return JSONResponse({"n": len(body)}) + + async def chunked_error(request: Request): + # Mimics FastAPI turning any body-read error into its own 400. + try: + await request.body() + except Exception: + return JSONResponse({"detail": "parse error"}, status_code=400) + return JSONResponse({"ok": True}) + + app = Starlette( + routes=[ + Route("/api/echo", echo, methods=["POST"]), + Route("/api/upload", echo, methods=["POST"]), + Route("/api/swallow", chunked_error, methods=["POST"]), + ] + ) + app.add_middleware(BodyLimitMiddleware, max_bytes=limit, registry=registry) + return app + + +def _client(app: Starlette) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") + + +async def _chunks(total: int, size: int = 100): + sent = 0 + while sent < total: + n = min(size, total - sent) + sent += n + yield b"x" * n + + +class TestContentLength: + async def test_within_limit_passes(self) -> None: + async with _client(_build()) as c: + r = await c.post("/api/echo", content=b"x" * LIMIT) + assert r.status_code == 200 and r.json() == {"n": LIMIT} + + async def test_over_limit_refused_before_route_runs(self) -> None: + reached = False + + async def spy(request: Request): + nonlocal reached + reached = True + return JSONResponse({}) + + app = Starlette(routes=[Route("/api/echo", spy, methods=["POST"])]) + app.add_middleware(BodyLimitMiddleware, max_bytes=LIMIT) + async with _client(app) as c: + r = await c.post("/api/echo", content=b"x" * (LIMIT + 1)) + assert r.status_code == 413 + assert r.json()["max_bytes"] == LIMIT + assert not reached + + +class TestChunked: + async def test_chunked_over_limit_refused(self) -> None: + async with _client(_build()) as c: + r = await c.post("/api/echo", content=_chunks(LIMIT * 3)) + assert r.status_code == 413 + + async def test_chunked_within_limit_passes(self) -> None: + async with _client(_build()) as c: + r = await c.post("/api/echo", content=_chunks(LIMIT)) + assert r.status_code == 200 and r.json() == {"n": LIMIT} + + async def test_app_that_converts_read_error_to_400_still_gets_413(self) -> None: + async with _client(_build()) as c: + r = await c.post("/api/swallow", content=_chunks(LIMIT * 3)) + assert r.status_code == 413 + + async def test_lying_content_length_is_still_counted(self) -> None: + # Declares a small body, streams a large one. + async with _client(_build()) as c: + r = await c.post( + "/api/echo", + content=_chunks(LIMIT * 3), + headers={"content-length": "10"}, + ) + assert r.status_code in (413, 400) # h11 may reject the mismatch first + + +class TestPerPathOverride: + async def test_override_raises_ceiling_for_one_path(self) -> None: + reg = BodyLimitRegistry() + reg.add_exact("/api/upload", LIMIT * 10, methods={"POST"}) + async with _client(_build(reg)) as c: + big = b"x" * (LIMIT * 5) + assert (await c.post("/api/upload", content=big)).status_code == 200 + assert (await c.post("/api/echo", content=big)).status_code == 413 + + async def test_callable_limit_receives_app(self) -> None: + reg = BodyLimitRegistry() + reg.add_prefix("/api/upload", lambda app: 50) + async with _client(_build(reg)) as c: + assert (await c.post("/api/upload", content=b"x" * 51)).status_code == 413 + assert (await c.post("/api/upload", content=b"x" * 50)).status_code == 200 + + async def test_zero_override_means_unlimited(self) -> None: + reg = BodyLimitRegistry() + reg.add_prefix("/api/upload", 0) + async with _client(_build(reg)) as c: + assert (await c.post("/api/upload", content=b"x" * 50_000)).status_code == 200 + + async def test_zero_global_disables_guard(self) -> None: + async with _client(_build(limit=0)) as c: + assert (await c.post("/api/echo", content=b"x" * 50_000)).status_code == 200 + + def test_registry_first_match_wins_and_methods_scope(self) -> None: + reg = BodyLimitRegistry() + reg.add_regex(r"/a/\d+$", 5, methods={"POST"}) + reg.add_prefix("/a", 9) + assert reg.limit_for("POST", "/a/1") == 5 + assert reg.limit_for("GET", "/a/1") == 9 + assert reg.limit_for("GET", "/b") is None + with pytest.raises(ValueError): + reg.add_prefix("/c", -1) + + +class TestWiredIntoApp: + """Through the real pipeline: ordering, JSON-vs-page negotiation, module hook.""" + + async def test_api_path_gets_json_413(self, client: httpx.AsyncClient) -> None: + r = await client.post("/api/users/auth/login", content=b"x" * (10 * 1024 * 1024 + 1)) + assert r.status_code == 413 + assert r.headers["content-type"].startswith("application/json") + assert r.json()["max_bytes"] == 10 * 1024 * 1024 + assert r.headers.get("x-correlation-id") # outside CorrelationId + + async def test_browser_request_gets_error_page(self, app, client: httpx.AsyncClient) -> None: + from fastapi import Form + + async def form_view(name: str = Form(...)): + return {"name": name} + + app.add_api_route("/__guard_probe/form", form_view, methods=["POST"]) + app.state.public_routes.add_exact("/__guard_probe/form") + big = b"name=" + b"x" * (10 * 1024 * 1024 + 1) + form = {"content-type": "application/x-www-form-urlencoded"} + # Plain browser navigation: the HTML error page. + r = await client.post( + "/__guard_probe/form", content=big, headers={**form, "accept": "text/html"} + ) + assert r.status_code == 413 + assert "text/html" in r.headers["content-type"] + # Inertia visit: the Error component, rendered with the full stack. + r = await client.post( + "/__guard_probe/form", + content=big, + headers={**form, "accept": "text/html", "x-inertia": "true"}, + ) + assert r.status_code == 413 + assert r.json()["component"] == "Error" + assert r.json()["props"]["status"] == 413 + + async def test_file_storage_upload_ceiling_follows_its_setting(self, app) -> None: + reg = app.state.body_limits + # 100 MB default + multipart headroom, resolved per request from settings. + limit = reg.limit_for("POST", "/api/file-storage/upload", app) + assert limit is not None and limit > 100 * 1024 * 1024 + assert reg.limit_for("GET", "/api/file-storage/upload", app) is None diff --git a/framework/hosting/tests/test_request_guard_body_limit_middleware_read.py b/framework/hosting/tests/test_request_guard_body_limit_middleware_read.py new file mode 100644 index 00000000..aad3c55a --- /dev/null +++ b/framework/hosting/tests/test_request_guard_body_limit_middleware_read.py @@ -0,0 +1,41 @@ +"""A browser-shaped oversized body read by a pre-router middleware is a 413, not a 500.""" + +from __future__ import annotations + +import httpx +from simple_module_hosting._body_limit import BodyLimitMiddleware +from starlette.applications import Starlette +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + + +class _FormGate(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + await request.body() + return await call_next(request) + + +def _client() -> httpx.AsyncClient: + async def ok(request: Request): + return JSONResponse({"ok": True}) + + app = Starlette(routes=[Route("/gate", ok, methods=["POST"])]) + app.add_middleware(_FormGate) + app.add_middleware(BodyLimitMiddleware, max_bytes=100) + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), base_url="http://t" + ) + + +async def test_middleware_body_read_over_limit_is_413_for_pages() -> None: + async with _client() as c: + r = await c.post("/gate", content=b"x" * 500, headers={"Accept": "text/html"}) + assert r.status_code == 413 + + +async def test_middleware_body_read_within_limit_passes() -> None: + async with _client() as c: + r = await c.post("/gate", content=b"x" * 50, headers={"Accept": "text/html"}) + assert r.status_code == 200 diff --git a/framework/hosting/tests/test_request_guard_body_limit_started.py b/framework/hosting/tests/test_request_guard_body_limit_started.py new file mode 100644 index 00000000..ad5f2eb9 --- /dev/null +++ b/framework/hosting/tests/test_request_guard_body_limit_started.py @@ -0,0 +1,67 @@ +"""Body guard when the app's response has already started (GH #345 review).""" + +from __future__ import annotations + +import pytest +from simple_module_hosting._body_limit import BodyLimitMiddleware, _BodyTooLargeError + +LIMIT = 1000 + + +async def test_started_response_is_not_swallowed_and_connection_aborts() -> None: + sent: list[dict] = [] + + async def app(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"part", "more_body": True}) + while True: # streaming echo: keeps reading the request body + await receive() + + chunks = iter([{"type": "http.request", "body": b"x" * 600, "more_body": True}] * 5) + + async def receive(): + return next(chunks) + + async def send(message): + sent.append(message) + + scope = { + "type": "http", + "method": "POST", + "path": "/api/echo", + "headers": [(b"accept", b"application/json")], + "query_string": b"", + } + with pytest.raises(_BodyTooLargeError): # the connection is aborted, not left hanging + await BodyLimitMiddleware(app, max_bytes=LIMIT)(scope, receive, send) + # What the app had already sent reached the client, and no second (413) + # response was started on top of it. + assert [m["type"] for m in sent] == ["http.response.start", "http.response.body"] + + +async def test_non_ascii_digit_content_length_does_not_500() -> None: + # "²".isdigit() is True but int("²") raises; that must not become a 500. + sent: list[dict] = [] + + async def app(scope, receive, send): + await receive() + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + msgs = iter([{"type": "http.request", "body": b"ok", "more_body": False}]) + + async def receive(): + return next(msgs) + + async def send(message): + sent.append(message) + + scope = { + "type": "http", + "method": "POST", + "path": "/api/echo", + "headers": [(b"accept", b"application/json"), (b"content-length", "²".encode("latin-1"))], + "query_string": b"", + } + await BodyLimitMiddleware(app, max_bytes=LIMIT)(scope, receive, send) + assert sent[0]["status"] == 200 diff --git a/framework/hosting/tests/test_request_guard_rate_limit.py b/framework/hosting/tests/test_request_guard_rate_limit.py new file mode 100644 index 00000000..1b5b0b99 --- /dev/null +++ b/framework/hosting/tests/test_request_guard_rate_limit.py @@ -0,0 +1,293 @@ +"""Shared rate limiter (GH #347): policy, 429 shape, per-rule override, Redis fail-open.""" + +from __future__ import annotations + +import httpx +import pytest +from simple_module_core.public_routes import PublicRouteRegistry +from simple_module_core.rate_limit import ( + InProcessWindowStore, + RateLimitError, + RateSpec, + parse_rate, +) +from simple_module_hosting._rate_limit import RateLimitMiddleware, RedisWindowStore +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route +from starlette.types import ASGIApp, Receive, Scope, Send + + +class _FakeClock: + def __init__(self) -> None: + self.now = 1_000.0 + + def __call__(self) -> float: + return self.now + + +class _Auth: + """Stand-in for AuthMiddleware: marks a request signed in via a header.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http" and (b"x-user", b"1") in scope["headers"]: + scope.setdefault("state", {})["user"] = object() + await self.app(scope, receive, send) + + +def _build( + public: PublicRouteRegistry, + *, + public_rate: str | None = "3/minute", + authenticated_rate: str | None = None, + store=None, +) -> Starlette: + async def ok(request): + return JSONResponse({"ok": True}) + + app = Starlette( + routes=[ + Route("/api/pub/thing", ok), + Route("/api/pub/fast", ok), + Route("/api/private/thing", ok), + ] + ) + app.state.public_routes = public + app.add_middleware( + RateLimitMiddleware, + public_rate=public_rate, + authenticated_rate=authenticated_rate, + store=store, + ) + app.add_middleware(_Auth) # outermost: runs first, like AuthMiddleware + return app + + +def _client(app: Starlette) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") + + +def _public() -> PublicRouteRegistry: + reg = PublicRouteRegistry() + reg.add_prefix("/api/pub/") + return reg + + +class TestParseRate: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + ("120/minute", RateSpec(120, 60)), + ("5/second", RateSpec(5, 1)), + ("10/10s", RateSpec(10, 10)), + ("1000/hours", RateSpec(1000, 3600)), + ("2/day", RateSpec(2, 86400)), + ], + ) + def test_valid(self, raw: str, expected: RateSpec) -> None: + assert parse_rate(raw) == expected + + @pytest.mark.parametrize("raw", [None, "", "off", "none", "0", "0/minute"]) + def test_disabled(self, raw) -> None: + assert parse_rate(raw) is None + + @pytest.mark.parametrize("raw", ["fast", "10", "10/fortnight", "/minute"]) + def test_invalid_raises(self, raw: str) -> None: + with pytest.raises(RateLimitError): + parse_rate(raw) + + def test_public_rule_validates_rate_at_registration(self) -> None: + with pytest.raises(RateLimitError): + PublicRouteRegistry().add_prefix("/x", rate="bogus") + + +class TestPolicy: + async def test_anonymous_public_route_gets_429_with_retry_after(self) -> None: + async with _client(_build(_public())) as c: + for _ in range(3): + assert (await c.get("/api/pub/thing")).status_code == 200 + r = await c.get("/api/pub/thing") + assert r.status_code == 429 + assert 1 <= int(r.headers["retry-after"]) <= 60 + assert r.json()["retry_after"] == int(r.headers["retry-after"]) + assert r.headers["content-type"].startswith("application/json") + + async def test_authenticated_traffic_untouched(self) -> None: + async with _client(_build(_public())) as c: + for _ in range(10): + r = await c.get("/api/pub/thing", headers={"x-user": "1"}) + assert r.status_code == 200 + + async def test_authenticated_opt_in(self) -> None: + app = _build(_public(), authenticated_rate="2/minute") + async with _client(app) as c: + codes = [ + (await c.get("/api/private/thing", headers={"x-user": "1"})).status_code + for _ in range(3) + ] + assert codes == [200, 200, 429] + + async def test_non_public_route_untouched(self) -> None: + async with _client(_build(_public())) as c: + for _ in range(10): + assert (await c.get("/api/private/thing")).status_code == 200 + + async def test_per_rule_override_replaces_default(self) -> None: + reg = PublicRouteRegistry() + reg.add_exact("/api/pub/fast", rate="1/minute") + reg.add_prefix("/api/pub/") + async with _client(_build(reg)) as c: + assert (await c.get("/api/pub/fast")).status_code == 200 + assert (await c.get("/api/pub/fast")).status_code == 429 + # a sibling still gets the default 3/minute — its own bucket + for _ in range(3): + assert (await c.get("/api/pub/thing")).status_code == 200 + assert (await c.get("/api/pub/thing")).status_code == 429 + + async def test_rule_rate_off_exempts_rule(self) -> None: + reg = PublicRouteRegistry() + reg.add_prefix("/api/pub/", rate="off") + async with _client(_build(reg)) as c: + for _ in range(10): + assert (await c.get("/api/pub/thing")).status_code == 200 + + async def test_disabled_default_skips_limiting(self) -> None: + async with _client(_build(_public(), public_rate="off")) as c: + for _ in range(10): + assert (await c.get("/api/pub/thing")).status_code == 200 + + async def test_buckets_are_per_client_ip(self) -> None: + app = _build(_public()) + transports = [ + httpx.ASGITransport(app=app, client=("1.1.1.1", 1)), + httpx.ASGITransport(app=app, client=("2.2.2.2", 1)), + ] + a = httpx.AsyncClient(transport=transports[0], base_url="http://t") + b = httpx.AsyncClient(transport=transports[1], base_url="http://t") + async with a, b: + for _ in range(3): + await a.get("/api/pub/thing") + assert (await a.get("/api/pub/thing")).status_code == 429 + assert (await b.get("/api/pub/thing")).status_code == 200 + + +class TestInProcessStore: + async def test_window_resets(self) -> None: + clock = _FakeClock() + store = InProcessWindowStore(clock=clock) + spec = RateSpec(2, 60) + assert (await store.hit("k", spec)).allowed + assert (await store.hit("k", spec)).allowed + denied = await store.hit("k", spec) + assert not denied.allowed and denied.retry_after == 60 + clock.now += 61 + assert (await store.hit("k", spec)).allowed + + +class _FakeRedis: + """Implements just the Lua script's contract: INCR + PEXPIRE + PTTL.""" + + def __init__(self) -> None: + self.counts: dict[str, int] = {} + self.calls = 0 + + async def eval(self, script: str, numkeys: int, key: str, period_ms: int): + self.calls += 1 + self.counts[key] = self.counts.get(key, 0) + 1 + return [self.counts[key], period_ms] + + +class _DeadRedis: + calls = 0 + + async def eval(self, *a, **k): + type(self).calls += 1 + raise ConnectionError("redis down") + + +class TestRedisStore: + async def test_counts_in_redis_and_limits(self) -> None: + fake = _FakeRedis() + app = _build(_public(), store=RedisWindowStore(fake)) + async with _client(app) as c: + codes = [(await c.get("/api/pub/thing")).status_code for _ in range(4)] + assert codes == [200, 200, 200, 429] + assert fake.calls == 4 + assert any(k.startswith("sm:rl:public:") for k in fake.counts) + + async def test_retry_after_comes_from_pttl(self) -> None: + store = RedisWindowStore(_FakeRedis()) + result = await store.hit("k", RateSpec(1, 30)) + assert result.retry_after == 30 + + async def test_redis_down_falls_back_to_per_worker_limit(self, caplog) -> None: + app = _build(_public(), store=RedisWindowStore(_DeadRedis())) + with caplog.at_level("WARNING", logger="simple_module.request_guard"): + async with _client(app) as c: + codes = [(await c.get("/api/pub/thing")).status_code for _ in range(10)] + assert codes[:3] == [200, 200, 200] # an outage must not take the site down... + assert set(codes[3:]) == {429} # ...nor silently remove the limit + warnings = [r for r in caplog.records if "per-worker counters" in r.getMessage()] + assert len(warnings) == 1 # throttled, not one per request + + async def test_redis_down_backs_off_instead_of_retrying_each_request(self) -> None: + _DeadRedis.calls = 0 + now = [100.0] + store = RedisWindowStore(_DeadRedis(), clock=lambda: now[0]) + for _ in range(20): + await store.hit("k", RateSpec(1, 60)) + assert _DeadRedis.calls == 1 + now[0] += 6 # backoff elapsed: Redis is probed again + await store.hit("k", RateSpec(1, 60)) + assert _DeadRedis.calls == 2 + + async def test_redis_recovers_and_limits_apply_again(self) -> None: + class _Flaky(_FakeRedis): + down = True + + async def eval(self, *a, **k): + if self.down: + raise ConnectionError("redis down") + return await super().eval(*a, **k) + + now = [100.0] + redis = _Flaky() + store = RedisWindowStore(redis, clock=lambda: now[0]) + spec = RateSpec(2, 60) + await store.hit("k", spec) # outage: served by the per-worker fallback + assert redis.calls == 0 + redis.down = False + now[0] += 6 # backoff elapsed: Redis is the source of truth again + results = [await store.hit("k", spec) for _ in range(3)] + assert [r.allowed for r in results] == [True, True, False] + assert redis.calls == 3 + + def test_script_heals_a_counter_without_ttl(self) -> None: + from simple_module_hosting._rate_limit import _LUA + + # A key that lost its TTL would block that IP forever; the script re-arms it. + assert "t < 0" in _LUA and "PEXPIRE" in _LUA + + +class TestWiredIntoApp: + async def test_real_pipeline_limits_registered_public_route(self, app, client) -> None: + app.state.public_routes.add_exact("/health", rate="2/minute") + codes = [(await client.get("/health")).status_code for _ in range(3)] + assert 429 not in codes[:2] + assert codes[2] == 429 + + async def test_authenticated_client_untouched(self, app, authenticated_client) -> None: + app.state.public_routes.add_exact("/health", rate="1/minute") + for _ in range(4): + assert (await authenticated_client.get("/health")).status_code != 429 + + async def test_html_request_gets_error_page(self, app, client) -> None: + app.state.public_routes.add_exact("/health", rate="1/minute") + await client.get("/health") + r = await client.get("/health", headers={"accept": "text/html", "x-inertia": "true"}) + assert r.status_code == 429 + assert r.json()["component"] == "Error" + assert "retry-after" in r.headers diff --git a/host/client_app/pages/Error.tsx b/host/client_app/pages/Error.tsx index c773adb5..013215d0 100644 --- a/host/client_app/pages/Error.tsx +++ b/host/client_app/pages/Error.tsx @@ -74,6 +74,11 @@ function useStatusCopy(status: number, maintenance: boolean): StatusCopy { description: t(e.session_expired_description), accent: 'warning', }, + 413: { + title: t(e.payload_too_large_title), + description: t(e.payload_too_large_description), + accent: 'warning', + }, 422: { title: t(e.invalid_request_title), description: t(e.invalid_request_description), diff --git a/host/locales/en.json b/host/locales/en.json index 18c36e24..b5d3fb41 100644 --- a/host/locales/en.json +++ b/host/locales/en.json @@ -20,6 +20,8 @@ "maintenance_title": "Down for maintenance", "not_found_description": "That page or record doesn't exist. It may have been deleted.", "not_found_title": "Not found", + "payload_too_large_description": "What you sent is larger than this server accepts. Send something smaller and try again.", + "payload_too_large_title": "Request too large", "rate_limited_description": "You've made a lot of requests in a short time. Wait a moment and try again.", "rate_limited_title": "Too many requests", "retry": "Retry", diff --git a/host/locales/es.json b/host/locales/es.json index 1769b498..a1c4ec54 100644 --- a/host/locales/es.json +++ b/host/locales/es.json @@ -20,6 +20,8 @@ "maintenance_title": "En mantenimiento", "not_found_description": "Esa página o registro no existe. Puede que se haya eliminado.", "not_found_title": "No encontrado", + "payload_too_large_description": "Lo que enviaste es mayor de lo que acepta este servidor. Envía algo más pequeño e inténtalo de nuevo.", + "payload_too_large_title": "Solicitud demasiado grande", "rate_limited_description": "Has hecho muchas solicitudes en poco tiempo. Espera un momento e inténtalo de nuevo.", "rate_limited_title": "Demasiadas solicitudes", "retry": "Reintentar", diff --git a/modules/auth/auth/middleware.py b/modules/auth/auth/middleware.py index cac24241..12074d50 100644 --- a/modules/auth/auth/middleware.py +++ b/modules/auth/auth/middleware.py @@ -76,6 +76,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: prefix_paths, exact_paths = provider.get_public_paths() is_public = any(path.startswith(p) for p in prefix_paths) or path in exact_paths + # Contract with the host's RateLimitMiddleware (plain scope key, no import + # between them): whether *this* middleware treats the request as anonymous- + # allowed, from all three sources above. One source of truth. + scope.setdefault("state", {})["auth_public"] = is_public + request = Request(scope) user_ctx = await provider.resolve_user(request) diff --git a/modules/file_storage/file_storage/constants.py b/modules/file_storage/file_storage/constants.py index 935b8824..7bd70659 100644 --- a/modules/file_storage/file_storage/constants.py +++ b/modules/file_storage/file_storage/constants.py @@ -99,6 +99,9 @@ class I18nKey: DEFAULT_BACKEND: Final = BackendId.FILESYSTEM DEFAULT_FS_ROOT: Final = "./uploads" DEFAULT_MAX_FILE_SIZE_BYTES: Final = 100 * 1024 * 1024 # 100 MB +# Multipart boundaries and part headers on top of the file bytes, for the +# host request-body guard. +MULTIPART_OVERHEAD_BYTES: Final = 1024 * 1024 DEFAULT_PRESIGN_TTL_SECONDS: Final = 300 # 5 minutes DEFAULT_CHUNK_SIZE: Final = 64 * 1024 # 64 KB SPOOL_MAX_SIZE_BYTES: Final = 10 * 1024 * 1024 # 10 MB before disk-spill diff --git a/modules/file_storage/file_storage/module.py b/modules/file_storage/file_storage/module.py index 73635c1a..2427a112 100644 --- a/modules/file_storage/file_storage/module.py +++ b/modules/file_storage/file_storage/module.py @@ -9,6 +9,7 @@ from fastapi import APIRouter from simple_module_core.audit_links import AuditLink, AuditLinkRegistry +from simple_module_core.body_limits import BodyLimitRegistry from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection from simple_module_core.module import ModuleBase, ModuleMeta @@ -152,6 +153,19 @@ def register_menu_items(self, registry: MenuRegistry) -> None: ) ) + def register_body_limits(self, registry: BodyLimitRegistry) -> None: + # The upload is multipart and its own ceiling (``max_file_size_bytes``, + # a runtime setting, 100 MB by default) is far above the host's global + # body guard. Track it, plus headroom for the multipart framing. + registry.add_exact( + constants.ROUTE_PREFIX_API + constants.PATH_UPLOAD, + lambda app: ( + app.state.file_storage.settings.max_file_size_bytes + + constants.MULTIPART_OVERHEAD_BYTES + ), + methods={"POST"}, + ) + def register_feature_flags(self, registry: FeatureFlagRegistry) -> None: registry.add( FeatureFlagDefinition( diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 25e4165c..48745ab0 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -436,6 +436,8 @@ export default { 'host.error.maintenance_title': '', 'host.error.not_found_description': '', 'host.error.not_found_title': '', + 'host.error.payload_too_large_description': '', + 'host.error.payload_too_large_title': '', 'host.error.rate_limited_description': '', 'host.error.rate_limited_title': '', 'host.error.retry': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 8b75c0e7..0b3e3bad 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -562,6 +562,8 @@ export const keys = { maintenance_title: 'host.error.maintenance_title', not_found_description: 'host.error.not_found_description', not_found_title: 'host.error.not_found_title', + payload_too_large_description: 'host.error.payload_too_large_description', + payload_too_large_title: 'host.error.payload_too_large_title', rate_limited_description: 'host.error.rate_limited_description', rate_limited_title: 'host.error.rate_limited_title', retry: 'host.error.retry',