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"
{template_name}: {context}
" + + +class LockedComponent(Component): + """Component with a locked server-trusted field.""" + + template_name = "locked.html" + locked_fields: ClassVar[frozenset[str]] = frozenset({"is_admin", "user_id"}) + + def mount(self): + super().mount() + self.state["is_admin"] = False + self.state["user_id"] = 42 + self.state["count"] = 0 + + def before_render(self): + # Server re-derives locked values on every request (documented pattern). + self.state.setdefault("is_admin", False) + self.state.setdefault("user_id", 42) + + def on_increment(self): + self.state["count"] = self.state.get("count", 0) + 1 + + +class ListDeclaredComponent(Component): + """Component declaring locked_fields as a list (normalization check).""" + + template_name = "listy.html" + locked_fields: ClassVar[list[str]] = ["secret_flag"] + + +class PlainComponent(Component): + """Component with the default (empty) locked_fields.""" + + template_name = "plain.html" + + +@pytest.fixture(autouse=True) +def _renderer(): + old = Component.renderer + Component.renderer = MockRenderer() + yield + Component.renderer = old + + +# ---------- Declaration ---------- + + +class TestDeclaration: + def test_default_is_empty(self): + assert frozenset(Component.locked_fields) == frozenset() + assert frozenset(PlainComponent.locked_fields) == frozenset() + + def test_frozenset_declaration(self): + assert LockedComponent._locked_field_set() == {"is_admin", "user_id"} + + def test_list_declaration_normalized(self): + assert ListDeclaredComponent._locked_field_set() == frozenset({"secret_flag"}) + assert isinstance(ListDeclaredComponent._locked_field_set(), frozenset) + + +# ---------- Hydrate stripping ---------- + + +class TestHydrateStripping: + def test_locked_field_stripped_on_hydrate(self): + component = LockedComponent() + component.hydrate({"count": 5, "is_admin": True}) + assert "is_admin" not in component.state + assert component.state["count"] == 5 + + def test_multiple_locked_fields_stripped(self): + component = LockedComponent() + component.hydrate({"is_admin": True, "user_id": 999, "count": 1}) + assert "is_admin" not in component.state + assert "user_id" not in component.state + assert component.state["count"] == 1 + + def test_unlocked_fields_pass_through(self): + component = LockedComponent() + component.hydrate({"count": 7, "other": "ok"}) + assert component.state == {"count": 7, "other": "ok"} + + def test_inbound_dict_sanitized_in_place(self): + """Stripping mutates the inbound dict so hydrate() overrides that + read the raw argument after super().hydrate() cannot see locked + values either (e.g. DjangoModelMixin.hydrate reads state["pk"]).""" + component = LockedComponent() + inbound = {"count": 1, "is_admin": True} + component.hydrate(inbound) + assert "is_admin" not in inbound + + def test_warning_emitted_for_stripped_fields(self, caplog): + component = LockedComponent() + with caplog.at_level(logging.WARNING, logger="component_framework.core.component"): + component.hydrate({"is_admin": True, "count": 1}) + assert any( + "is_admin" in record.getMessage() and "LockedComponent" in record.getMessage() + for record in caplog.records + ) + + def test_no_warning_when_no_locked_fields_inbound(self, caplog): + component = LockedComponent() + with caplog.at_level(logging.WARNING, logger="component_framework.core.component"): + component.hydrate({"count": 1}) + assert not caplog.records + + def test_empty_default_is_noop(self, caplog): + component = PlainComponent() + with caplog.at_level(logging.WARNING, logger="component_framework.core.component"): + component.hydrate({"anything": "goes", "is_admin": True}) + assert component.state == {"anything": "goes", "is_admin": True} + assert not caplog.records + + +# ---------- Dehydrate exclusion ---------- + + +class TestDehydrateExclusion: + def test_locked_fields_excluded_from_dehydrate(self): + component = LockedComponent() + component.mount() + serialized = component.dehydrate() + assert "is_admin" not in serialized + assert "user_id" not in serialized + assert serialized["count"] == 0 + + def test_locked_fields_remain_in_server_state(self): + component = LockedComponent() + component.mount() + component.dehydrate() + assert component.state["is_admin"] is False + assert component.state["user_id"] == 42 + + def test_dispatch_response_state_excludes_locked_fields(self): + result = LockedComponent().dispatch() + assert "is_admin" not in result["state"] + assert "user_id" not in result["state"] + + def test_empty_default_dehydrate_unchanged(self): + component = PlainComponent() + component.hydrate({"a": 1}) + assert component.dehydrate() == {"a": 1} + + +# ---------- Full round trip: signing disabled ---------- + + +class TestUnsignedRoundTrip: + def test_tampered_locked_field_does_not_stick(self): + # Initial mount on the server. + first = LockedComponent().dispatch() + assert "is_admin" not in first["state"] + + # Client tampers with (or injects) the locked field and echoes back. + tampered = dict(first["state"]) + tampered["is_admin"] = True + + second = LockedComponent(component_id=first["component_id"]).dispatch( + event="increment", state=tampered + ) + # Server-derived value wins; the injected value never sticks. + assert "is_admin" not in second["state"] + assert second["state"]["count"] == 1 + + def test_async_dispatch_strips_locked_fields(self): + import asyncio + + result = asyncio.run(LockedComponent().async_dispatch(state={"is_admin": True, "count": 3})) + assert "is_admin" not in result["state"] + assert result["state"]["count"] == 3 + + +# ---------- Full round trip: signing enabled ---------- + + +class TestSignedRoundTrip: + def test_signed_round_trip_strips_locked_fields(self): + StateSigner.configure("secret-key") + first = LockedComponent().dispatch() + token = StateSerializer.serialize(first["state"]) + assert token.startswith("cfs1.") + + inbound = StateSerializer.load_untrusted(token) + assert inbound is not None + # Even a validly signed blob cannot smuggle a locked field. + inbound["is_admin"] = True + second = LockedComponent().dispatch(event="increment", state=inbound) + assert "is_admin" not in second["state"] + + def test_replay_of_stale_signed_state_does_not_roll_back_locked_field(self): + """Replay scenario: an OLD validly-signed blob (captured before the + field was locked / before a server-side change) must not roll a + locked field back to its stale value.""" + StateSigner.configure("secret-key") + + # Stale-but-validly-signed blob containing a locked field, e.g. + # captured from an older deployment where the field round-tripped. + stale_token = StateSerializer.serialize({"count": 0, "is_admin": True, "user_id": 1}) + + inbound = StateSerializer.load_untrusted(stale_token) + result = LockedComponent().dispatch(state=inbound) + + # before_render() re-derives the server-trusted values. + assert result["state"].get("is_admin") is None # never serialized out + component = LockedComponent() + component.hydrate({"count": 0, "is_admin": True, "user_id": 1}) + component.before_render() + assert component.state["is_admin"] is False + assert component.state["user_id"] == 42 + + +# ---------- FormComponent interaction ---------- + + +class LockedForm(FormComponent): + """Form with a locked owner field.""" + + template_name = "locked_form.html" + locked_fields: ClassVar[frozenset[str]] = frozenset({"owner_id"}) + + def mount(self): + super().mount() + self.state["owner_id"] = 7 + + +class TestFormComponentInteraction: + def test_form_locked_field_stripped_on_hydrate(self): + form = LockedForm() + form.hydrate({"form_data": {"name": "x"}, "owner_id": 999}) + assert "owner_id" not in form.state + assert form.state["form_data"] == {"name": "x"} + + def test_form_locked_field_excluded_from_dehydrate(self): + form = LockedForm() + form.mount() + serialized = form.dehydrate() + assert "owner_id" not in serialized + assert "form_data" in serialized + + def test_form_submit_flow_unaffected(self): + result = LockedForm().dispatch( + event="submit", + payload={"form_data": {"name": "widget"}}, + state={"form_data": {}, "submitted": False, "is_valid": False, "owner_id": 999}, + ) + assert result["state"]["submitted"] is True + assert result["state"]["is_valid"] is True + assert result["state"]["form_data"] == {"name": "widget"} + assert "owner_id" not in result["state"]