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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **HMAC-signed client state (Epic A, A1 — #21)** — new stdlib-only
`core/signing.py` with `StateSigner` (HMAC-SHA256, versioned
`cfs1.<payload>.<mac>` token format) and `CorruptStateError`. Enable via
`StateSigner.configure(secret)` or the `STATE_SIGNING_KEY` environment
variable; comma-separated / sequence values enable key rotation (first key
signs, all keys verify). When enabled, all outbound state is signed and
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`.

### Security

- Closed the **dict bypass**: all adapters (FastAPI, Flask, Litestar, Django
FBV/CBV, WebSocket) now route inbound state through
`StateSerializer.load_untrusted()`, so a client can no longer submit state
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`.

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

### Added
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,9 @@ Generated from docstrings by pdoc and deployed to GitHub Pages on every push to
| [Architecture Overview](docs/server_component_spec.md) | Core design and component lifecycle |
| [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 |
| [E-Commerce Example](docs/examples/ecommerce.md) | Real-time cart + product demo |
| [Multi-Step Wizard](docs/examples/wizard.md) | FastAPI wizard recipe (unsigned state — pending Epic A1) |
| [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 |

### AI / LLM Context
Expand Down Expand Up @@ -593,7 +594,7 @@ and [epic issues](https://github.com/fsecada01/component-framework/issues?q=is%3
> optimistic UI once it lands).

**0.6.0b — Hardening Foundation** *(Tier 0: security & rendering fidelity)*
- [ ] Signed / tamper-proof state (HMAC) — *security gate*
- [x] Signed / tamper-proof state (HMAC) — *security gate* — see [State Signing](docs/STATE_SIGNING.md)
- [ ] DOM morphing — preserve focus / scroll / in-flight input; stable list keys
- [ ] CSRF coverage for FastAPI / Litestar / Flask HTTP paths (today Django-only)
- [ ] 422 re-render on form-validation failure (adapters currently return 200)
Expand Down
148 changes: 148 additions & 0 deletions docs/STATE_SIGNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# State Signing (HMAC)

Component state round-trips through the client on every dispatch: the server
returns a serialized `state` field, and the client echoes it back with the
next event. Without a signature, a client can tamper with any state field
before echoing it back.

State signing closes this gap. When enabled, every outbound state string is
an HMAC-SHA256 signed token, and every inbound state **must** be a valid
token — plain JSON strings and raw JSON objects are rejected with
`CorruptStateError` (HTTP 400 on all adapters).

## Token format

```
cfs1.<base64url(json_payload)>.<base64url(hmac_sha256_mac)>
```

The MAC covers `cfs1.<payload>` — the version prefix is bound into the
signature, so tokens cannot be replayed across future format versions.

## Enabling signing

Signing is configured once per process, at application startup. There are two
equivalent mechanisms:

### 1. Environment variable (no code changes)

```bash
export STATE_SIGNING_KEY="$(openssl rand -hex 32)"
```

Comma-separated values enable key rotation (first key signs, all verify):

```bash
export STATE_SIGNING_KEY="new-key,old-key"
```

### 2. Explicit configuration

```python
from component_framework import StateSigner

StateSigner.configure("your-secret-key") # single key
StateSigner.configure(["new-key", "old-key"]) # rotation
StateSigner.configure(None) # explicitly disable (overrides env)
```

`StateSigner.configure()` takes precedence over the environment variable.

If neither is set, signing is **disabled** and the framework falls back to
legacy plain-JSON state — a one-time warning is logged. Do not run production
deployments unsigned.

## Per-adapter setup

The signer is process-global and lives below the adapter layer, so setup is
the same everywhere: configure it before the app starts serving.

### FastAPI

```python
from fastapi import FastAPI
from component_framework import StateSigner
from component_framework.adapters.fastapi import create_component_routes

StateSigner.configure(os.environ["MY_SECRET"]) # or just set STATE_SIGNING_KEY

app = FastAPI()
create_component_routes(app)
```

### Litestar

```python
from litestar import Litestar
from component_framework import StateSigner
from component_framework.adapters.litestar import component_endpoint

StateSigner.configure(os.environ["MY_SECRET"])

app = Litestar(route_handlers=[component_endpoint])
```

### Flask

```python
from flask import Flask
from component_framework import StateSigner
from component_framework.adapters.flask import register_component_routes

app = Flask(__name__)
StateSigner.configure(app.config["SECRET_KEY"]) # reusing SECRET_KEY is fine
register_component_routes(app)
```

### Django

Configure in `settings.py` (or an `AppConfig.ready()` hook):

```python
# settings.py
from component_framework import StateSigner

StateSigner.configure(SECRET_KEY) # or a dedicated key
```

## Key rotation procedure

1. **Prepend** the new key: `STATE_SIGNING_KEY="new-key,old-key"` (or
`StateSigner.configure(["new-key", "old-key"])`). New responses are signed
with `new-key`; states still in flight (signed with `old-key`) continue to
verify.
2. **Wait** until in-flight client states have expired — i.e. long enough that
no open browser tab still holds a state signed with the old key. For most
apps a deploy cycle or a day is plenty.
3. **Drop** the old key: `STATE_SIGNING_KEY="new-key"`. Old tokens now fail
verification (clients simply re-mount their components).

## WebSocket pushes

WebSocket traffic goes through the same `StateSerializer` choke point:
inbound `component_event` messages are verified, and both dispatch responses
and server-initiated `push_update()` broadcasts carry signed state.

## Error handling

Tampered, truncated, unsigned, or foreign-key state raises
`component_framework.CorruptStateError` (a `ComponentError` subclass). The
bundled adapters map it to:

| Adapter | Response |
|---------|----------|
| FastAPI | `400` (`HTTPException`) |
| Litestar | `400` (`HTTPException`) |
| Flask | `400` JSON error |
| Django (FBV + CBV) | `400` `JsonResponse` |
| WebSocket | `{"type": "error", ...}` frame |

## What signing does and does not protect

- **Protects:** integrity — the client cannot alter state fields, forge
state, or splice state across format versions.
- **Does not protect:** confidentiality — the payload is base64-encoded JSON,
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).
8 changes: 8 additions & 0 deletions src/component_framework/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Component Framework — server-driven components for Python web frameworks."""

from .core.signing import CorruptStateError, StateSigner

__all__ = [
"CorruptStateError",
"StateSigner",
]
33 changes: 20 additions & 13 deletions src/component_framework/adapters/django_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,11 @@ def component_view(request: HttpRequest, name: str) -> JsonResponse:
except json.JSONDecodeError as e:
return JsonResponse({"error": f"Invalid JSON in payload/params: {e}"}, status=400)

# Deserialize state
state = None
if state_str:
try:
state = StateSerializer.deserialize(state_str)
except Exception as e:
return JsonResponse({"error": f"Invalid state: {e}"}, status=400)
# Deserialize state (verified against the signing key when enabled)
try:
state = StateSerializer.load_untrusted(state_str)
except Exception as e:
return JsonResponse({"error": f"Invalid state: {e}"}, status=400)

# Compute optimistic patch BEFORE dispatch (uses pre-dispatch state).
# Hydrate state so get_optimistic_patch can access current state values.
Expand Down Expand Up @@ -254,12 +252,14 @@ def parse_payload(self, payload_str: str | dict) -> dict:
except json.JSONDecodeError as e:
raise ValueError(f"Invalid payload JSON: {e}")

def parse_state(self, state_str: str | None) -> dict | None:
"""Parse state JSON string."""
if not state_str:
return None
def parse_state(self, state_raw: str | dict | None) -> dict | None:
"""Parse client-supplied state (signed token, JSON string, or dict).

Routes through :meth:`StateSerializer.load_untrusted` so raw dicts
cannot bypass signature verification when signing is enabled.
"""
try:
return StateSerializer.deserialize(state_str)
return StateSerializer.load_untrusted(state_raw)
except Exception as e:
raise ValueError(f"Invalid state: {e}")

Expand Down Expand Up @@ -304,7 +304,14 @@ def component_not_found(self, name: str) -> JsonResponse:
return JsonResponse({"error": f"Component '{name}' not found"}, status=404)

def handle_error(self, error: Exception, name: str) -> JsonResponse:
"""Handle errors."""
"""Handle errors.

Client input errors (bad JSON, tampered/corrupt state) map to 400;
everything else maps to 500.
"""
if isinstance(error, ValueError):
logger.warning("Bad request for component '%s': %s", name, error)
return JsonResponse({"error": str(error)}, status=400)
logger.exception(f"Error processing component '{name}'")
return JsonResponse({"error": "Internal server error"}, status=500)

Expand Down
14 changes: 4 additions & 10 deletions src/component_framework/adapters/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,10 @@ def _extract_params(data: dict) -> tuple[dict, str | None, dict, dict | None]:
params = _parse_json_str(data.get("params"), default={}) or {}
event = data.get("event")
payload = _parse_json_str(data.get("payload"), default={}) or {}
state_raw = data.get("state")

state = None
if state_raw:
try:
state = (
StateSerializer.deserialize(state_raw) if isinstance(state_raw, str) else state_raw
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid state: {e}")
try:
state = StateSerializer.load_untrusted(data.get("state"))
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid state: {e}")

return params, event, payload, state

Expand Down
14 changes: 4 additions & 10 deletions src/component_framework/adapters/flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,10 @@ def _extract_params(data: dict) -> tuple[dict, str | None, dict, dict | None]:
params = _parse_json_str(data.get("params"), default={}) or {}
event = data.get("event")
payload = _parse_json_str(data.get("payload"), default={}) or {}
state_raw = data.get("state")

state = None
if state_raw:
try:
state = (
StateSerializer.deserialize(state_raw) if isinstance(state_raw, str) else state_raw
)
except Exception as e:
raise ValueError(f"Invalid state: {e}")
try:
state = StateSerializer.load_untrusted(data.get("state"))
except Exception as e:
raise ValueError(f"Invalid state: {e}")

return params, event, payload, state

Expand Down
14 changes: 4 additions & 10 deletions src/component_framework/adapters/litestar.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,10 @@ def _extract_params(data: dict) -> tuple[dict, str | None, dict, dict | None]:
params = _parse_json_str(data.get("params"), default={}) or {}
event = data.get("event")
payload = _parse_json_str(data.get("payload"), default={}) or {}
state_raw = data.get("state")

state = None
if state_raw:
try:
state = (
StateSerializer.deserialize(state_raw) if isinstance(state_raw, str) else state_raw
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid state: {e}")
try:
state = StateSerializer.load_untrusted(data.get("state"))
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid state: {e}")

return params, event, payload, state

Expand Down
3 changes: 3 additions & 0 deletions src/component_framework/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@
)
from .registry import ComponentRegistry, registry
from .renderer import Renderer
from .signing import CorruptStateError, StateSigner
from .state import InMemoryStateStore, StateStore
from .streaming import StreamingComponent, format_sse_frame
from .websocket import ComponentWebSocketManager, WebSocketConnection, ws_manager

__all__ = [
"Component",
"ComponentError",
"CorruptStateError",
"EventNotFoundError",
"StateSerializer",
"StateSigner",
"compose",
"SlotRenderer",
"FormComponent",
Expand Down
Loading
Loading