Summary
The store mutation endpoint /api/_store/{api_id}/* accepts POST/PATCH/DELETE against any plain Store with no authorization. The store ID is embedded in every rendered page (SuperStore.inject_stores), so any visitor can read and overwrite shared server state — global Stores are effectively world-writable, and there is no CSRF token on the state-changing verbs.
Where
src/htealeaf/state/store.py:44 SuperStore.process — dispatches GET/POST/PATCH/DELETE with no auth check for plain Store.
src/htealeaf/state/store.py:22 inject_stores — emits new Store("<id>") into the page for every store, exposing the ID.
Notes / current mitigations
AuthStore is correctly partitioned per session via store.auth(session) — no IDOR there; this is specifically about plain Store.
SameSite=Lax on the session cookie blocks cross-site POST, so this is mostly a same-origin concern (any user can clobber another user's view of shared state). This may be intended for the "global shared reactive state" model.
Suggested direction
- Document explicitly that a plain
Store is global and unauthenticated shared state (not for per-user or trusted data).
- Optionally add a CSRF token for state-changing verbs, and/or an opt-in authorization hook on
Store for cases where global-but-guarded state is wanted.
Why it matters
Even if intended, the trust boundary should be explicit so users don't put sensitive or per-user data in a plain Store assuming it's protected. Found during a security review of develop.
Summary
The store mutation endpoint
/api/_store/{api_id}/*acceptsPOST/PATCH/DELETEagainst any plainStorewith no authorization. The store ID is embedded in every rendered page (SuperStore.inject_stores), so any visitor can read and overwrite shared server state — globalStores are effectively world-writable, and there is no CSRF token on the state-changing verbs.Where
src/htealeaf/state/store.py:44SuperStore.process— dispatches GET/POST/PATCH/DELETE with no auth check for plainStore.src/htealeaf/state/store.py:22inject_stores— emitsnew Store("<id>")into the page for every store, exposing the ID.Notes / current mitigations
AuthStoreis correctly partitioned per session viastore.auth(session)— no IDOR there; this is specifically about plainStore.SameSite=Laxon the session cookie blocks cross-site POST, so this is mostly a same-origin concern (any user can clobber another user's view of shared state). This may be intended for the "global shared reactive state" model.Suggested direction
Storeis global and unauthenticated shared state (not for per-user or trusted data).Storefor cases where global-but-guarded state is wanted.Why it matters
Even if intended, the trust boundary should be explicit so users don't put sensitive or per-user data in a plain
Storeassuming it's protected. Found during a security review ofdevelop.