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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
108 changes: 108 additions & 0 deletions docs/LOCKED_FIELDS.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 3 additions & 1 deletion docs/STATE_SIGNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
59 changes: 56 additions & 3 deletions src/component_framework/core/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down
Loading
Loading