diff --git a/CHANGELOG.md b/CHANGELOG.md index 383a899..d73fbb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 inbound state must be a valid token — tampered, unsigned, or raw-dict state is rejected with HTTP 400. When disabled, legacy plain-JSON behavior is preserved and a one-time warning is logged. See `docs/STATE_SIGNING.md`. +- **Locked server-trusted state fields (Epic A, A3 — #21)** — components can + declare `locked_fields: ClassVar[frozenset[str]]` for top-level state keys + the client must never influence (roles, user IDs, pricing). Locked fields + are excluded from `dehydrate()` (they never round-trip through the client) + and stripped in place from inbound state in `hydrate()` with a warning. + Enforcement lives in the core lifecycle, so all adapters are covered + without changes. Defaults to empty — existing components are unaffected. + See `docs/LOCKED_FIELDS.md`. ### Security @@ -27,6 +35,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 as a raw JSON object to skip deserialization/verification. - Django `ComponentView.handle_error()` now maps client input errors (`ValueError`, including corrupt state) to `400` instead of `500`. +- Locked fields close the **replay/rollback gap** left by A1: even a + validly-signed but stale state blob (or any state in signing-disabled + deployments) can no longer roll back server-owned fields — inbound locked + values are ignored and the server re-derives them each request. ## [0.5.0b0] - 2026-06-24 diff --git a/README.md b/README.md index 8291d10..4799832 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,7 @@ Generated from docstrings by pdoc and deployed to GitHub Pages on every push to | [Django Implementation](docs/DJANGO_IMPLEMENTATION.md) | Django adapter setup and patterns | | [Class-Based Views](docs/CBV_GUIDE.md) | CBV auth/permission patterns | | [State Signing](docs/STATE_SIGNING.md) | HMAC-signed client state: setup per adapter + key rotation | +| [Locked Fields](docs/LOCKED_FIELDS.md) | Server-trusted state fields the client can never influence (replay/rollback defense) | | [E-Commerce Example](docs/examples/ecommerce.md) | Real-time cart + product demo | | [Multi-Step Wizard](docs/examples/wizard.md) | FastAPI wizard recipe (enable [state signing](docs/STATE_SIGNING.md) in production) | | [CSRF & CSWSH Guide](docs/SECURITY_CSRF.md) | Per-adapter CSRF coverage audit + WebSocket hijacking guidance | diff --git a/docs/LOCKED_FIELDS.md b/docs/LOCKED_FIELDS.md new file mode 100644 index 0000000..acdb6d1 --- /dev/null +++ b/docs/LOCKED_FIELDS.md @@ -0,0 +1,108 @@ +# Locked Fields (Server-Trusted State) + +Component state round-trips through the client on every dispatch. [State +signing](STATE_SIGNING.md) guarantees *integrity* — the client cannot alter a +signed blob — but it does not make state trustworthy: + +1. **Unsigned deployments have zero integrity.** If signing is not enabled, + the client can freely edit any state field before echoing it back. +2. **Even with signing, replay is possible.** A client can capture an *older* + validly-signed state blob and replay it to roll fields back — e.g. a token + from before their `is_admin` flag was revoked, a stale `user_id`, or an + old price. + +Locked fields are the defense for values the **server owns**: fields the +client must never influence, in either mode. + +## Declaring locked fields + +```python +from typing import ClassVar + +from component_framework import Component, registry + + +@registry.register("account_panel") +class AccountPanel(Component): + template_name = "account_panel.html" + locked_fields: ClassVar[frozenset[str]] = frozenset({"is_admin", "user_id"}) + + def mount(self): + super().mount() + self.state["user_id"] = self.params["user"].id + self.state["is_admin"] = self.params["user"].is_staff + self.state["tab"] = "profile" + + def before_render(self): + # Re-derive server-trusted values on EVERY request — hydrated + # requests do not run mount(). + user = self.params.get("user") + self.state["is_admin"] = bool(user and user.is_staff) +``` + +Any iterable of strings works (`frozenset`, `list`, `tuple`); it is +normalized internally. The default is empty — existing components are +unaffected. + +## Enforcement semantics + +Locked fields are enforced at the core lifecycle choke points, so every +adapter (FastAPI, Flask, Litestar, Django FBV/CBV, WebSocket, SSE streaming) +is covered without adapter changes: + +- **Outbound (`dehydrate()`):** locked fields are excluded from the + serialized state — they never reach the client at all. `self.state` keeps + them server-side for the rest of the request. +- **Inbound (`hydrate()`):** locked fields present in client-supplied state + are stripped *before* `self.state` is updated, and a warning is logged + naming the fields (never the values). Because outbound state never + contains locked fields, any inbound occurrence is anomalous — crafted + state, or a replayed blob from before the field was locked. + +The inbound dict is sanitized **in place**, so `hydrate()` overrides that +read the raw argument after `super().hydrate(state)` (e.g. +`DjangoModelMixin.hydrate` reading `state["pk"]`) cannot see locked values +either. + +## Re-establishing locked values + +Since locked values never round-trip, the component must (re)derive them +server-side on every request. Pick whichever fits: + +| Pattern | When to use | +|---------|-------------| +| `before_render()` | Value only needed for rendering | +| `hydrate()` override (set after `super().hydrate(state)`) | Value needed by event handlers (handlers run *before* `before_render()`) | +| Server-supplied `params` (adapter/dependency injection) | Value comes from the request context (current user, tenant, …) | +| Look up inside the handler | Value only needed by one handler | + +## Interaction with signing + +Locked fields work identically in both modes and complement signing: + +- **Signing disabled:** locked fields are the only integrity guarantee for + the fields they cover. Everything else in state remains client-editable — + enable signing for production. +- **Signing enabled:** signing blocks tampering; locked fields additionally + block **replay/rollback** of stale-but-validly-signed values for the + covered fields. + +## Caveats + +- **Top-level keys only.** `locked_fields` matches top-level state keys. + Nested values (e.g. one field inside `FormComponent`'s + `state["form_data"]` dict) cannot be individually locked — keep + server-trusted values in their own top-level keys, not inside form data. +- **Event payloads are not covered.** Locked fields guard the *state* + channel. Event payloads (including `FormComponent`'s `submit` payload, + which writes `form_data` directly) are separate inputs — validate them + with your Pydantic `schema` and authorization checks in handlers. +- **`ModelFormComponent` / `DjangoModelComponent`:** the bound instance is + loaded server-side, but `pk` round-trips by design so the mixin can reload + the instance. Locking `pk` therefore breaks instance reloading — instead, + authorize access in `get_instance()` (scope the queryset to the current + user) rather than trusting the client-supplied `pk`. +- **Don't put secrets in state.** Locked or not, state design guidance from + [State Signing](STATE_SIGNING.md) still applies; locked fields are about + *trust*, not confidentiality (in fact they never leave the server, which + also helps confidentiality — but that is a side effect, not a contract). diff --git a/docs/STATE_SIGNING.md b/docs/STATE_SIGNING.md index 5f79956..146a47f 100644 --- a/docs/STATE_SIGNING.md +++ b/docs/STATE_SIGNING.md @@ -145,4 +145,6 @@ bundled adapters map it to: readable by the client. Never put secrets in component state. - **Does not prevent replay of a stale-but-valid token** for the same component. Treat state as untrusted input in handlers and enforce - authorization server-side (see Epic A3, locked fields, for the follow-up). + authorization server-side. For fields the server owns (roles, IDs, + pricing), declare them as [locked fields](LOCKED_FIELDS.md) — they never + round-trip through the client, so replayed blobs cannot roll them back. diff --git a/src/component_framework/core/component.py b/src/component_framework/core/component.py index d602496..eb0e396 100644 --- a/src/component_framework/core/component.py +++ b/src/component_framework/core/component.py @@ -47,6 +47,16 @@ class Component: permission_classes: ClassVar[list[type["BasePermission"]]] = [] slots: ClassVar[list[str]] = [] """Slot names this component accepts. Empty list means *any* slot name is accepted.""" + locked_fields: ClassVar[frozenset[str] | list[str] | tuple[str, ...]] = frozenset() + """Server-trusted top-level state fields the client must never influence. + + Locked fields are excluded from :meth:`dehydrate` (they never reach the + client) and stripped from inbound state in :meth:`hydrate` (a replayed or + forged value is ignored, with a warning). Components must (re)establish + locked values server-side on every request — in a ``hydrate()`` override, + in ``before_render()``, or from server-supplied ``params``. + See ``docs/LOCKED_FIELDS.md``. + """ def __init__(self, **params): self.params = params @@ -67,13 +77,56 @@ def mount(self): self._mounted = True def hydrate(self, state: dict): - """Restore component from serialized state.""" + """Restore component from serialized state. + + Fields declared in :attr:`locked_fields` are removed from *state* + (in place, so ``hydrate()`` overrides that read the raw argument + after ``super().hydrate()`` cannot see them either) and a warning is + logged — locked fields are server-trusted and never accepted from + the client. + """ + self._strip_locked_fields(state) self.state.update(state) self._mounted = True def dehydrate(self) -> dict: - """Serialize component state for persistence.""" - return self.state.copy() + """Serialize component state for persistence. + + Fields declared in :attr:`locked_fields` are excluded — they never + round-trip through the client. ``self.state`` itself is untouched, + so locked values remain available server-side for the rest of the + request. + """ + state = self.state.copy() + for field in self._locked_field_set().intersection(state): + del state[field] + return state + + @classmethod + def _locked_field_set(cls) -> frozenset[str]: + """Return :attr:`locked_fields` normalized to a frozenset.""" + return frozenset(cls.locked_fields) + + def _strip_locked_fields(self, state: dict) -> None: + """Remove locked fields from inbound client *state*, in place. + + Logs a warning naming the stripped fields (values are deliberately + not logged). No-op when :attr:`locked_fields` is empty. + """ + locked = self._locked_field_set() + if not locked: + return + stripped = sorted(locked.intersection(state)) + if not stripped: + return + for field in stripped: + del state[field] + logger.warning( + "Ignoring locked state field(s) %s in inbound client state for %s — " + "locked fields are server-trusted and never accepted from the client.", + stripped, + self.__class__.__name__, + ) def before_render(self): """Called before rendering. Use for derived state computation.""" diff --git a/tests/test_locked_fields.py b/tests/test_locked_fields.py new file mode 100644 index 0000000..61c91c1 --- /dev/null +++ b/tests/test_locked_fields.py @@ -0,0 +1,276 @@ +"""Tests for locked server-trusted state fields (Epic A, task A3).""" + +import logging +from typing import ClassVar + +import pytest + +from component_framework.core.component import Component, StateSerializer +from component_framework.core.form import FormComponent +from component_framework.core.renderer import Renderer +from component_framework.core.signing import StateSigner + + +@pytest.fixture(autouse=True) +def _reset_signer(monkeypatch): + """Isolate signer configuration per test (env + class state).""" + monkeypatch.delenv("STATE_SIGNING_KEY", raising=False) + StateSigner.reset() + yield + StateSigner.reset() + + +class MockRenderer(Renderer): + """Mock renderer for testing.""" + + def render(self, template_name: str, context: dict) -> str: + return f"