Skip to content

Commit b748e50

Browse files
authored
fix(serve): bootstrap session tokens for instance-token-authenticated front-ends (#116)
Sessions created by one front-end (e.g. bodek, or the WebUI in another browser profile) could not be loaded by another: every new session gets a 256-bit AuthToken that only reaches the creating client, and the token bootstrap only covered legacy sessions with no token at all - so GET /api/sessions/<id> returned 401 and the WebUI showed "Failed to load session". The GET handler now also bootstraps the session token when the caller proves knowledge of the per-instance CSRF token by presenting it in the X-Odek-Ws-Token header (constant-time compared). Header presentation is a knowledge proof a cross-origin page cannot forge: a DNS-rebinding page holds only the ambient SameSite=Strict cookie and can neither read the token value nor set the custom header (CORS preflight, which odek does not answer). Cookie-only callers therefore still get 401 on token-carrying sessions, while the operator's legitimate front-ends - which always send the header via apiHeaders - can load each other's sessions. The rebinding damage boundary is unchanged; DELETE/POST (rename) flows inherit the fix through the client's existing ensureSessionToken GET bootstrap. Regression tests cover: bootstrap with a valid instance header (200 + X-Session-Token), no bootstrap with a wrong instance header (401), and no bootstrap for cookie-only callers (401). Docs updated (SECURITY.md section 24, AGENTS.md).
1 parent 9ae5e46 commit b748e50

4 files changed

Lines changed: 114 additions & 17 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
199199
- **Memory add/replace pipe-to-shell filter** (`internal/memory/memory.go`) — `AddFact` and `ReplaceFact` now run `FactLooksUnsafe` in addition to the general guard scan, blocking agent-driven planting of download-and-execute facts.
200200
- **Skill learn-loop provenance propagation** (`internal/skills/learnloop.go`) — conversation-extracted suggestions and LLM-enhanced suggestions both retain the session's `SkillProvenance`, so tainted sessions cannot produce clean-looking auto-saved skills.
201201
- **Audit ingest recording for @-refs, `--ctx`, and Web-UI attachments** (`cmd/odek/refs.go`, `cmd/odek/serve.go`, `cmd/odek/audit.go`) — the per-session ingest recorder is attached before `@`-reference/`--ctx`/attachment resolution, `recordTurnAudit` scans `user` messages for untrusted wrappers in addition to `tool` messages, and the divergence heuristic compares agent actions against the original pre-enrichment prompt so attacker-injected resources are not treated as user-mentioned.
202-
- **Session ID entropy + session-scoped auth tokens** (`internal/session/session.go`, `cmd/odek/serve.go`) — session IDs now carry 128 bits of randomness (16 bytes / 32 hex chars); each session stores a 256-bit `AuthToken` required by `GET/DELETE/POST /api/sessions/<id>`, `POST /api/cancel`, and WebSocket session-resume messages via `X-Session-Token` header, `session_token` cookie, or `auth_token` WS field. Per-IP rate limiting (60/min) on session lookups adds a brute-force backstop. `X-Forwarded-For` / `X-Real-Ip` headers are only honored when the direct remote address is in the configured `trusted_proxies` list, so spoofed headers cannot bypass the limiters.
202+
- **Session ID entropy + session-scoped auth tokens** (`internal/session/session.go`, `cmd/odek/serve.go`) — session IDs now carry 128 bits of randomness (16 bytes / 32 hex chars); each session stores a 256-bit `AuthToken` required by `GET/DELETE/POST /api/sessions/<id>`, `POST /api/cancel`, and WebSocket session-resume messages via `X-Session-Token` header, `session_token` cookie, or `auth_token` WS field. Per-IP rate limiting (60/min) on session lookups adds a brute-force backstop. `X-Forwarded-For` / `X-Real-Ip` headers are only honored when the direct remote address is in the configured `trusted_proxies` list, so spoofed headers cannot bypass the limiters. Callers that present the per-instance CSRF token in the `X-Odek-Ws-Token` header (a knowledge proof a cross-origin page cannot forge) are bootstrapped the session token on `GET`, so the operator's front-ends (WebUI, bodek) can load each other's sessions; cookie-only callers still get 401.
203203
- **Skill/episode untrusted wrapper** (`internal/loop/loop.go` + `odek.go`) — skill context (both auto-load and lazy trigger-matched), the `skill_load` tool output, and retrieved session-episode context are passed through the caller-provided untrusted wrapper (the same nonce'd `<untrusted_content_*>` boundary used for tool output) before being injected into the model's system context. This prevents a compromised or tainted skill/episode from being treated as trusted system instructions.
204204
- **`env` / `printenv` environment-dump gate** (`internal/danger/classifier.go`) — bare `env` and `printenv` invocations are classified as `system_write` because they can leak process-environment secrets that the redaction scanner does not recognise. `env VAR=value <cmd>` still classifies `<cmd>` normally.
205205
- **Web UI attachment wrapping** (`cmd/odek/serve.go` + `cmd/odek/ui/app.js`) — files attached through the browser are sent separately from the user's text and wrapped with the nonce'd `<untrusted_content_*>` boundary (`source="attachment:<filename>"`) before injection into the prompt.

cmd/odek/serve.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ func serveCmd(args []string) error {
333333
}
334334
mux.Handle("/api/resources", apiAuth(handleResourceSearch(resourceReg)))
335335
mux.Handle("/api/sessions", apiAuth(handleSessionList(store)))
336-
mux.Handle("/api/sessions/", apiAuth(handleSessionByID(store, resolved.TrustedProxies)))
336+
mux.Handle("/api/sessions/", apiAuth(handleSessionByID(store, resolved.TrustedProxies, wsToken)))
337337
mux.Handle("/api/models", apiAuth(handleModelList(resolved.Model)))
338338
mux.Handle("/api/cancel", apiAuth(handleCancel(store)))
339339

@@ -1440,6 +1440,22 @@ func requireServeToken(token string) func(http.Handler) http.Handler {
14401440
}
14411441
}
14421442

1443+
// presentsInstanceTokenHeader reports whether the request carries the
1444+
// per-instance CSRF token in the X-Odek-Ws-Token header. Header presentation
1445+
// is a KNOWLEDGE proof — unlike the ambient SameSite=Strict cookie, a
1446+
// cross-origin (e.g. DNS-rebinding) page can neither read the token value
1447+
// nor set the header (the custom header triggers a CORS preflight, which
1448+
// odek does not answer). It therefore distinguishes legitimate front-ends
1449+
// (the WebUI, bodek, curl with the token URL) from a rebinding page that
1450+
// merely holds the cookie.
1451+
func presentsInstanceTokenHeader(r *http.Request, wsToken string) bool {
1452+
if wsToken == "" {
1453+
return false
1454+
}
1455+
h := r.Header.Get(wsTokenHeaderName)
1456+
return h != "" && subtle.ConstantTimeCompare([]byte(h), []byte(wsToken)) == 1
1457+
}
1458+
14431459
// sessionTokenFromRequest returns the session auth token from the
14441460
// X-Session-Token header or the session_token cookie, in that order.
14451461
func sessionTokenFromRequest(r *http.Request) string {
@@ -1541,7 +1557,7 @@ func handleSessionList(store *session.Store) http.HandlerFunc {
15411557
}
15421558
}
15431559

1544-
func handleSessionByID(store *session.Store, trustedProxies []string) http.HandlerFunc {
1560+
func handleSessionByID(store *session.Store, trustedProxies []string, wsToken string) http.HandlerFunc {
15451561
return func(w http.ResponseWriter, r *http.Request) {
15461562
id := strings.TrimPrefix(r.URL.Path, "/api/sessions/")
15471563
if id == "" {
@@ -1564,6 +1580,17 @@ func handleSessionByID(store *session.Store, trustedProxies []string) http.Handl
15641580
}
15651581
token := sessionTokenFromRequest(r)
15661582
effectiveToken, ok := validateSessionToken(store, sess, token)
1583+
if !ok && presentsInstanceTokenHeader(r, wsToken) {
1584+
// Bootstrap the session token for callers that prove
1585+
// knowledge of the per-instance CSRF token via the
1586+
// X-Odek-Ws-Token header. Without this, sessions created by
1587+
// one front-end (e.g. bodek, or a WebUI in another browser
1588+
// profile) can never be loaded by another: the per-session
1589+
// token only reaches the client that created the session,
1590+
// and the legacy bootstrap only covers sessions that have
1591+
// no token at all.
1592+
effectiveToken, ok = sess.AuthToken, true
1593+
}
15671594
if !ok {
15681595
http.Error(w, "invalid session token", http.StatusUnauthorized)
15691596
return

cmd/odek/serve_api_test.go

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func TestHandleSessionByID_GET_ReturnsSession(t *testing.T) {
7070
t.Fatalf("Create session: %v", err)
7171
}
7272

73-
handler := handleSessionByID(store, nil)
73+
handler := handleSessionByID(store, nil, "")
7474
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
7575
req.Header.Set("X-Session-Token", sess.AuthToken)
7676
w := httptest.NewRecorder()
@@ -106,7 +106,7 @@ func TestHandleSessionByID_GET_ReturnsSession(t *testing.T) {
106106

107107
func TestHandleSessionByID_GET_NotFound(t *testing.T) {
108108
store := newTestSessionStore(t)
109-
handler := handleSessionByID(store, nil)
109+
handler := handleSessionByID(store, nil, "")
110110

111111
// Valid ID format but doesn't exist on disk.
112112
req := httptest.NewRequest(http.MethodGet, "/api/sessions/20260101-aabbcc", nil)
@@ -120,7 +120,7 @@ func TestHandleSessionByID_GET_NotFound(t *testing.T) {
120120

121121
func TestHandleSessionByID_GET_MissingID(t *testing.T) {
122122
store := newTestSessionStore(t)
123-
handler := handleSessionByID(store, nil)
123+
handler := handleSessionByID(store, nil, "")
124124

125125
req := httptest.NewRequest(http.MethodGet, "/api/sessions/", nil)
126126
w := httptest.NewRecorder()
@@ -145,7 +145,7 @@ func TestHandleSessionByID_GET_MessagesArePresent(t *testing.T) {
145145
t.Fatalf("Create: %v", err)
146146
}
147147

148-
handler := handleSessionByID(store, nil)
148+
handler := handleSessionByID(store, nil, "")
149149
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
150150
req.Header.Set("X-Session-Token", sess.AuthToken)
151151
w := httptest.NewRecorder()
@@ -161,7 +161,7 @@ func TestHandleSessionByID_GET_MessagesArePresent(t *testing.T) {
161161

162162
func TestHandleSessionByID_MethodNotAllowed(t *testing.T) {
163163
store := newTestSessionStore(t)
164-
handler := handleSessionByID(store, nil)
164+
handler := handleSessionByID(store, nil, "")
165165

166166
for _, method := range []string{http.MethodPatch, http.MethodPut, http.MethodHead} {
167167
req := httptest.NewRequest(method, "/api/sessions/20260101-aabbcc", nil)
@@ -182,7 +182,7 @@ func TestHandleSessionByID_DELETE_StillWorks(t *testing.T) {
182182
t.Fatalf("Create: %v", err)
183183
}
184184

185-
handler := handleSessionByID(store, nil)
185+
handler := handleSessionByID(store, nil, "")
186186
req := httptest.NewRequest(http.MethodDelete, "/api/sessions/"+sess.ID, nil)
187187
req.Header.Set("X-Session-Token", sess.AuthToken)
188188
w := httptest.NewRecorder()
@@ -209,7 +209,7 @@ func TestHandleSessionByID_POST_RenameStillWorks(t *testing.T) {
209209
t.Fatalf("Create: %v", err)
210210
}
211211

212-
handler := handleSessionByID(store, nil)
212+
handler := handleSessionByID(store, nil, "")
213213
body := strings.NewReader(`{"name":"renamed task"}`)
214214
req := httptest.NewRequest(http.MethodPost, "/api/sessions/"+sess.ID, body)
215215
req.Header.Set("Content-Type", "application/json")
@@ -232,7 +232,7 @@ func TestHandleSessionByID_GET_InvalidToken(t *testing.T) {
232232
store := newTestSessionStore(t)
233233
sess, _ := store.Create([]llm.Message{{Role: "user", Content: "hi"}}, "m", "task")
234234

235-
handler := handleSessionByID(store, nil)
235+
handler := handleSessionByID(store, nil, "")
236236
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
237237
req.Header.Set("X-Session-Token", "wrong-token")
238238
w := httptest.NewRecorder()
@@ -247,7 +247,7 @@ func TestHandleSessionByID_GET_MissingToken(t *testing.T) {
247247
store := newTestSessionStore(t)
248248
sess, _ := store.Create([]llm.Message{{Role: "user", Content: "hi"}}, "m", "task")
249249

250-
handler := handleSessionByID(store, nil)
250+
handler := handleSessionByID(store, nil, "")
251251
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
252252
w := httptest.NewRecorder()
253253
handler(w, req)
@@ -268,7 +268,7 @@ func TestHandleSessionByID_GET_LazyTokenBootstrap(t *testing.T) {
268268
t.Fatalf("Save: %v", err)
269269
}
270270

271-
handler := handleSessionByID(store, nil)
271+
handler := handleSessionByID(store, nil, "")
272272
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
273273
w := httptest.NewRecorder()
274274
handler(w, req)
@@ -297,11 +297,79 @@ func TestHandleSessionByID_GET_LazyTokenBootstrap(t *testing.T) {
297297
}
298298
}
299299

300+
func TestHandleSessionByID_GET_InstanceHeaderBootstrap(t *testing.T) {
301+
// Cross-front-end interop: a session created by another client (e.g.
302+
// bodek) carries an auth token the browser doesn't have. When the caller
303+
// proves knowledge of the per-instance CSRF token via the
304+
// X-Odek-Ws-Token header, the GET bootstraps the session token.
305+
store := newTestSessionStore(t)
306+
sess, _ := store.Create([]llm.Message{{Role: "user", Content: "hi"}}, "m", "task")
307+
const wsToken = "test-instance-token"
308+
309+
handler := handleSessionByID(store, nil, wsToken)
310+
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
311+
req.Header.Set(wsTokenHeaderName, wsToken)
312+
w := httptest.NewRecorder()
313+
handler(w, req)
314+
315+
if w.Code != http.StatusOK {
316+
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
317+
}
318+
if got := w.Header().Get("X-Session-Token"); got != sess.AuthToken {
319+
t.Errorf("X-Session-Token = %q, want bootstrapped session token %q", got, sess.AuthToken)
320+
}
321+
322+
// The bootstrapped token works for subsequent requests (rename/delete).
323+
req2 := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
324+
req2.Header.Set("X-Session-Token", sess.AuthToken)
325+
w2 := httptest.NewRecorder()
326+
handler(w2, req2)
327+
if w2.Code != http.StatusOK {
328+
t.Errorf("second GET status = %d, want 200", w2.Code)
329+
}
330+
}
331+
332+
func TestHandleSessionByID_GET_InstanceHeaderBootstrap_WrongInstanceToken(t *testing.T) {
333+
// A wrong instance token proves nothing — no bootstrap.
334+
store := newTestSessionStore(t)
335+
sess, _ := store.Create([]llm.Message{{Role: "user", Content: "hi"}}, "m", "task")
336+
337+
handler := handleSessionByID(store, nil, "real-token")
338+
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
339+
req.Header.Set(wsTokenHeaderName, "wrong-token")
340+
w := httptest.NewRecorder()
341+
handler(w, req)
342+
343+
if w.Code != http.StatusUnauthorized {
344+
t.Errorf("status = %d, want 401", w.Code)
345+
}
346+
}
347+
348+
func TestHandleSessionByID_GET_NoBootstrapWithoutInstanceHeader(t *testing.T) {
349+
// Ambient-cookie-only callers (e.g. a DNS-rebinding page that cannot set
350+
// custom headers) must NOT get the bootstrap even with the instance
351+
// token configured: without the X-Odek-Ws-Token header the request is
352+
// indistinguishable from the pre-fix behavior.
353+
store := newTestSessionStore(t)
354+
sess, _ := store.Create([]llm.Message{{Role: "user", Content: "hi"}}, "m", "task")
355+
356+
handler := handleSessionByID(store, nil, "real-token")
357+
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
358+
// No X-Session-Token, no X-Odek-Ws-Token — only a (valid) instance cookie.
359+
req.AddCookie(&http.Cookie{Name: wsTokenCookieName, Value: "real-token"})
360+
w := httptest.NewRecorder()
361+
handler(w, req)
362+
363+
if w.Code != http.StatusUnauthorized {
364+
t.Errorf("status = %d, want 401", w.Code)
365+
}
366+
}
367+
300368
func TestHandleSessionByID_DELETE_RequiresToken(t *testing.T) {
301369
store := newTestSessionStore(t)
302370
sess, _ := store.Create([]llm.Message{{Role: "user", Content: "hi"}}, "m", "task")
303371

304-
handler := handleSessionByID(store, nil)
372+
handler := handleSessionByID(store, nil, "")
305373
req := httptest.NewRequest(http.MethodDelete, "/api/sessions/"+sess.ID, nil)
306374
req.Header.Set("X-Session-Token", "wrong-token")
307375
w := httptest.NewRecorder()
@@ -316,7 +384,7 @@ func TestHandleSessionByID_POST_RequiresToken(t *testing.T) {
316384
store := newTestSessionStore(t)
317385
sess, _ := store.Create([]llm.Message{{Role: "user", Content: "hi"}}, "m", "task")
318386

319-
handler := handleSessionByID(store, nil)
387+
handler := handleSessionByID(store, nil, "")
320388
body := strings.NewReader(`{"name":"renamed"}`)
321389
req := httptest.NewRequest(http.MethodPost, "/api/sessions/"+sess.ID, body)
322390
req.Header.Set("Content-Type", "application/json")
@@ -336,7 +404,7 @@ func TestHandleSessionByID_GET_RateLimit(t *testing.T) {
336404
sessionLookupLimiter.reset()
337405
defer sessionLookupLimiter.reset()
338406

339-
handler := handleSessionByID(store, nil)
407+
handler := handleSessionByID(store, nil, "")
340408
// Exhaust the 60/min allowance.
341409
for i := 0; i < 60; i++ {
342410
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
@@ -741,7 +809,7 @@ func buildServeMuxWithSessionByID(t *testing.T, store *session.Store) (net.Liste
741809
apiAuth := func(h http.Handler) http.Handler {
742810
return requireServeToken(token)(requireLocalHost(requireLocalOrigin(h)))
743811
}
744-
mux.Handle("/api/sessions/", apiAuth(handleSessionByID(store, nil)))
812+
mux.Handle("/api/sessions/", apiAuth(handleSessionByID(store, nil, "")))
745813
return ln, mux
746814
}
747815

docs/SECURITY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,8 @@ The IP used for rate limiting is taken from `X-Forwarded-For` / `X-Real-Ip` only
467467

468468
Legacy sessions created before this defense have no `AuthToken`; the first access bootstraps one and returns it to the client, preserving backward compatibility without weakening protection for newly created sessions.
469469

470+
**Cross-front-end bootstrap (header knowledge proof).** A session created by one front-end (e.g. bodek, or the WebUI in another browser profile) carries an `AuthToken` the other front-ends don't have, so it could not be loaded by them. `GET /api/sessions/<id>` now also bootstraps the session token when the caller proves **knowledge of the per-instance CSRF token** by presenting it in the `X-Odek-Ws-Token` header (constant-time compared). Header presentation is a knowledge proof a cross-origin page cannot forge: a DNS-rebinding page holds only the ambient `SameSite=Strict` cookie and can neither read the token value nor set the custom header (it triggers a CORS preflight odek does not answer). Cookie-only callers therefore still get 401 on token-carrying sessions, while the operator's legitimate front-ends — which always send the header — can load each other's sessions. The rebinding damage boundary is unchanged.
471+
470472
### 25. Skill and episode context wrapped as untrusted
471473

472474
Skill content and retrieved session episodes are externally-sourced data that cross the trust boundary. Before injecting them as `system` messages, the auto-load path (`odek.go`) and the lazy-match path (the loop) pass them through the same nonce'd `<untrusted_content_*>` wrapper used for tool output; the `skill_load` tool output is wrapped the same way at registration. The skill manager already gates `NeedsReview`/tainted skills, and the memory manager filters tainted episodes from search, but the wrapper provides defense-in-depth so a compromised skill or episode cannot pose as trusted system instructions.

0 commit comments

Comments
 (0)