Skip to content

Commit 5acbb7f

Browse files
fsecada01claude
andauthored
feat(core): locked server-trusted state fields (A3, #21) (#37)
Components can declare locked_fields (ClassVar iterable of top-level state keys) for values the server owns and the client must never influence — roles, user IDs, pricing. Enforcement at the core lifecycle choke points covers every adapter without changes: - dehydrate() excludes locked fields, so they never round-trip through the client (self.state keeps them server-side) - hydrate() strips locked fields from inbound state in place and logs a warning naming the fields (never the values) This closes the replay/rollback gap left by A1 signing (a stale but validly-signed blob can no longer roll back server-owned fields) and provides the only integrity guarantee for covered fields in signing-disabled deployments. Defaults to empty — existing components unaffected (477 tests green, 21 new). Claude-Session: https://claude.ai/code/session_01JpsdVdokfe732D9aQKTXXQ Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f4a6b65 commit 5acbb7f

6 files changed

Lines changed: 456 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
inbound state must be a valid token — tampered, unsigned, or raw-dict state
1919
is rejected with HTTP 400. When disabled, legacy plain-JSON behavior is
2020
preserved and a one-time warning is logged. See `docs/STATE_SIGNING.md`.
21+
- **Locked server-trusted state fields (Epic A, A3 — #21)** — components can
22+
declare `locked_fields: ClassVar[frozenset[str]]` for top-level state keys
23+
the client must never influence (roles, user IDs, pricing). Locked fields
24+
are excluded from `dehydrate()` (they never round-trip through the client)
25+
and stripped in place from inbound state in `hydrate()` with a warning.
26+
Enforcement lives in the core lifecycle, so all adapters are covered
27+
without changes. Defaults to empty — existing components are unaffected.
28+
See `docs/LOCKED_FIELDS.md`.
2129

2230
### Security
2331

@@ -27,6 +35,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2735
as a raw JSON object to skip deserialization/verification.
2836
- Django `ComponentView.handle_error()` now maps client input errors
2937
(`ValueError`, including corrupt state) to `400` instead of `500`.
38+
- Locked fields close the **replay/rollback gap** left by A1: even a
39+
validly-signed but stale state blob (or any state in signing-disabled
40+
deployments) can no longer roll back server-owned fields — inbound locked
41+
values are ignored and the server re-derives them each request.
3042

3143
## [0.5.0b0] - 2026-06-24
3244

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ Generated from docstrings by pdoc and deployed to GitHub Pages on every push to
239239
| [Django Implementation](docs/DJANGO_IMPLEMENTATION.md) | Django adapter setup and patterns |
240240
| [Class-Based Views](docs/CBV_GUIDE.md) | CBV auth/permission patterns |
241241
| [State Signing](docs/STATE_SIGNING.md) | HMAC-signed client state: setup per adapter + key rotation |
242+
| [Locked Fields](docs/LOCKED_FIELDS.md) | Server-trusted state fields the client can never influence (replay/rollback defense) |
242243
| [E-Commerce Example](docs/examples/ecommerce.md) | Real-time cart + product demo |
243244
| [Multi-Step Wizard](docs/examples/wizard.md) | FastAPI wizard recipe (enable [state signing](docs/STATE_SIGNING.md) in production) |
244245
| [CSRF & CSWSH Guide](docs/SECURITY_CSRF.md) | Per-adapter CSRF coverage audit + WebSocket hijacking guidance |

docs/LOCKED_FIELDS.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Locked Fields (Server-Trusted State)
2+
3+
Component state round-trips through the client on every dispatch. [State
4+
signing](STATE_SIGNING.md) guarantees *integrity* — the client cannot alter a
5+
signed blob — but it does not make state trustworthy:
6+
7+
1. **Unsigned deployments have zero integrity.** If signing is not enabled,
8+
the client can freely edit any state field before echoing it back.
9+
2. **Even with signing, replay is possible.** A client can capture an *older*
10+
validly-signed state blob and replay it to roll fields back — e.g. a token
11+
from before their `is_admin` flag was revoked, a stale `user_id`, or an
12+
old price.
13+
14+
Locked fields are the defense for values the **server owns**: fields the
15+
client must never influence, in either mode.
16+
17+
## Declaring locked fields
18+
19+
```python
20+
from typing import ClassVar
21+
22+
from component_framework import Component, registry
23+
24+
25+
@registry.register("account_panel")
26+
class AccountPanel(Component):
27+
template_name = "account_panel.html"
28+
locked_fields: ClassVar[frozenset[str]] = frozenset({"is_admin", "user_id"})
29+
30+
def mount(self):
31+
super().mount()
32+
self.state["user_id"] = self.params["user"].id
33+
self.state["is_admin"] = self.params["user"].is_staff
34+
self.state["tab"] = "profile"
35+
36+
def before_render(self):
37+
# Re-derive server-trusted values on EVERY request — hydrated
38+
# requests do not run mount().
39+
user = self.params.get("user")
40+
self.state["is_admin"] = bool(user and user.is_staff)
41+
```
42+
43+
Any iterable of strings works (`frozenset`, `list`, `tuple`); it is
44+
normalized internally. The default is empty — existing components are
45+
unaffected.
46+
47+
## Enforcement semantics
48+
49+
Locked fields are enforced at the core lifecycle choke points, so every
50+
adapter (FastAPI, Flask, Litestar, Django FBV/CBV, WebSocket, SSE streaming)
51+
is covered without adapter changes:
52+
53+
- **Outbound (`dehydrate()`):** locked fields are excluded from the
54+
serialized state — they never reach the client at all. `self.state` keeps
55+
them server-side for the rest of the request.
56+
- **Inbound (`hydrate()`):** locked fields present in client-supplied state
57+
are stripped *before* `self.state` is updated, and a warning is logged
58+
naming the fields (never the values). Because outbound state never
59+
contains locked fields, any inbound occurrence is anomalous — crafted
60+
state, or a replayed blob from before the field was locked.
61+
62+
The inbound dict is sanitized **in place**, so `hydrate()` overrides that
63+
read the raw argument after `super().hydrate(state)` (e.g.
64+
`DjangoModelMixin.hydrate` reading `state["pk"]`) cannot see locked values
65+
either.
66+
67+
## Re-establishing locked values
68+
69+
Since locked values never round-trip, the component must (re)derive them
70+
server-side on every request. Pick whichever fits:
71+
72+
| Pattern | When to use |
73+
|---------|-------------|
74+
| `before_render()` | Value only needed for rendering |
75+
| `hydrate()` override (set after `super().hydrate(state)`) | Value needed by event handlers (handlers run *before* `before_render()`) |
76+
| Server-supplied `params` (adapter/dependency injection) | Value comes from the request context (current user, tenant, …) |
77+
| Look up inside the handler | Value only needed by one handler |
78+
79+
## Interaction with signing
80+
81+
Locked fields work identically in both modes and complement signing:
82+
83+
- **Signing disabled:** locked fields are the only integrity guarantee for
84+
the fields they cover. Everything else in state remains client-editable —
85+
enable signing for production.
86+
- **Signing enabled:** signing blocks tampering; locked fields additionally
87+
block **replay/rollback** of stale-but-validly-signed values for the
88+
covered fields.
89+
90+
## Caveats
91+
92+
- **Top-level keys only.** `locked_fields` matches top-level state keys.
93+
Nested values (e.g. one field inside `FormComponent`'s
94+
`state["form_data"]` dict) cannot be individually locked — keep
95+
server-trusted values in their own top-level keys, not inside form data.
96+
- **Event payloads are not covered.** Locked fields guard the *state*
97+
channel. Event payloads (including `FormComponent`'s `submit` payload,
98+
which writes `form_data` directly) are separate inputs — validate them
99+
with your Pydantic `schema` and authorization checks in handlers.
100+
- **`ModelFormComponent` / `DjangoModelComponent`:** the bound instance is
101+
loaded server-side, but `pk` round-trips by design so the mixin can reload
102+
the instance. Locking `pk` therefore breaks instance reloading — instead,
103+
authorize access in `get_instance()` (scope the queryset to the current
104+
user) rather than trusting the client-supplied `pk`.
105+
- **Don't put secrets in state.** Locked or not, state design guidance from
106+
[State Signing](STATE_SIGNING.md) still applies; locked fields are about
107+
*trust*, not confidentiality (in fact they never leave the server, which
108+
also helps confidentiality — but that is a side effect, not a contract).

docs/STATE_SIGNING.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,6 @@ bundled adapters map it to:
145145
readable by the client. Never put secrets in component state.
146146
- **Does not prevent replay of a stale-but-valid token** for the same
147147
component. Treat state as untrusted input in handlers and enforce
148-
authorization server-side (see Epic A3, locked fields, for the follow-up).
148+
authorization server-side. For fields the server owns (roles, IDs,
149+
pricing), declare them as [locked fields](LOCKED_FIELDS.md) — they never
150+
round-trip through the client, so replayed blobs cannot roll them back.

src/component_framework/core/component.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ class Component:
4747
permission_classes: ClassVar[list[type["BasePermission"]]] = []
4848
slots: ClassVar[list[str]] = []
4949
"""Slot names this component accepts. Empty list means *any* slot name is accepted."""
50+
locked_fields: ClassVar[frozenset[str] | list[str] | tuple[str, ...]] = frozenset()
51+
"""Server-trusted top-level state fields the client must never influence.
52+
53+
Locked fields are excluded from :meth:`dehydrate` (they never reach the
54+
client) and stripped from inbound state in :meth:`hydrate` (a replayed or
55+
forged value is ignored, with a warning). Components must (re)establish
56+
locked values server-side on every request — in a ``hydrate()`` override,
57+
in ``before_render()``, or from server-supplied ``params``.
58+
See ``docs/LOCKED_FIELDS.md``.
59+
"""
5060

5161
def __init__(self, **params):
5262
self.params = params
@@ -67,13 +77,56 @@ def mount(self):
6777
self._mounted = True
6878

6979
def hydrate(self, state: dict):
70-
"""Restore component from serialized state."""
80+
"""Restore component from serialized state.
81+
82+
Fields declared in :attr:`locked_fields` are removed from *state*
83+
(in place, so ``hydrate()`` overrides that read the raw argument
84+
after ``super().hydrate()`` cannot see them either) and a warning is
85+
logged — locked fields are server-trusted and never accepted from
86+
the client.
87+
"""
88+
self._strip_locked_fields(state)
7189
self.state.update(state)
7290
self._mounted = True
7391

7492
def dehydrate(self) -> dict:
75-
"""Serialize component state for persistence."""
76-
return self.state.copy()
93+
"""Serialize component state for persistence.
94+
95+
Fields declared in :attr:`locked_fields` are excluded — they never
96+
round-trip through the client. ``self.state`` itself is untouched,
97+
so locked values remain available server-side for the rest of the
98+
request.
99+
"""
100+
state = self.state.copy()
101+
for field in self._locked_field_set().intersection(state):
102+
del state[field]
103+
return state
104+
105+
@classmethod
106+
def _locked_field_set(cls) -> frozenset[str]:
107+
"""Return :attr:`locked_fields` normalized to a frozenset."""
108+
return frozenset(cls.locked_fields)
109+
110+
def _strip_locked_fields(self, state: dict) -> None:
111+
"""Remove locked fields from inbound client *state*, in place.
112+
113+
Logs a warning naming the stripped fields (values are deliberately
114+
not logged). No-op when :attr:`locked_fields` is empty.
115+
"""
116+
locked = self._locked_field_set()
117+
if not locked:
118+
return
119+
stripped = sorted(locked.intersection(state))
120+
if not stripped:
121+
return
122+
for field in stripped:
123+
del state[field]
124+
logger.warning(
125+
"Ignoring locked state field(s) %s in inbound client state for %s — "
126+
"locked fields are server-trusted and never accepted from the client.",
127+
stripped,
128+
self.__class__.__name__,
129+
)
77130

78131
def before_render(self):
79132
"""Called before rendering. Use for derived state computation."""

0 commit comments

Comments
 (0)