Skip to content

Commit 8648827

Browse files
committed
fix(security): medium findings #65, #66, #70, #56
- #66: shell reads of ~/.odek trust anchors now classify as system_write - #65: bare env/printenv environment dumps classify as system_write - #70: Web UI file attachments wrapped as untrusted before prompt injection - #56: per-instance random CSRF token required for /ws upgrades - token injected into index.html meta tag and HttpOnly SameSite=Strict cookie - accepted via cookie, X-Odek-Ws-Token header, or odek.<token> subprotocol - added regression tests and updated SECURITY.md / AGENTS.md
1 parent 23e9ed8 commit 8648827

12 files changed

Lines changed: 513 additions & 107 deletions

AGENTS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
9999
- **FD-based API key handoff** (`cmd/odek/subagent_key.go`) — parent writes key to a 0600 tempfile, immediately `unlink()`s, passes the FD via `cmd.ExtraFiles`. Sub-agent reads from `$ODEK_API_KEY_FD` and closes. Key never in `/proc/<pid>/environ`.
100100
- **Approver friction** (`internal/danger/approver.go`, `cmd/odek/wsapprover.go`) — both TTYApprover and WSApprover engage friction mode after 3 approvals of the same class in 60s: require typing literal `approve`, 1.5s pause. Trust-class shortcut disabled for `destructive` + `blocked` regardless.
101101
- **Danger classifier bypass resistance** (`internal/danger/classifier.go`) — `normalize()` pre-processes: expand `$IFS` / `${IFS}`, extract `$(...)` / `` `...` `` substitutions, strip `command` / `exec` / `builtin` wrappers, collapse unquoted backslashes, basename absolute paths. `awk`/`gawk`, `sed` (`e` command / `-f`), and editors (`vi`/`vim`/`nvim`/`emacs`/`ed`/`ex`) are classified as `code_execution` when given a script or file operand, closing `awk 'BEGIN{system(...)}'`, `sed 's///e'`, and editor `!` shell escapes. Regression suite in `classifier_bypass_test.go`.
102-
- **WS Origin allowlist** (`cmd/odek/serve.go::checkLocalOrigin`) — rejects non-localhost upgrades. Closes CSRF-on-localhost.
102+
- **WebSocket CSRF token** (`cmd/odek/serve.go`) — `odek serve` issues a random 256-bit token at startup, injects it into `index.html`, sets an `HttpOnly` `SameSite=Strict` cookie, and requires it on `/ws` via cookie, `X-Odek-Ws-Token` header, or `odek.<token>` subprotocol. The localhost origin check remains as defense-in-depth.
103103
- **REST API CSRF protection** (`cmd/odek/serve.go::requireLocalOrigin`) — state-changing HTTP endpoints (POST/PUT/PATCH/DELETE) require a localhost origin or no Origin header, and static responses set `X-Frame-Options: DENY` + `Content-Security-Policy: frame-ancestors 'none'` to block clickjacking.
104104
- **Browser history cap** (`cmd/odek/browser_tool.go`) — navigation history is capped at 50 snapshots to prevent memory DoS from repeated `browser_navigate` calls.
105105
- **Browser element cap** (`cmd/odek/browser_tool.go`) — the number of interactive elements extracted per page is capped at 500 so a hostile page cannot OOM the agent with thousands of links or buttons.
@@ -142,7 +142,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
142142
- **Schedule cross-process lock hard error** (`internal/schedule/store.go`) — `fileLock` now returns an error instead of silently falling back to a no-op releaser when `~/.odek/schedules.lock` cannot be opened or locked. Mutating schedule operations abort rather than risk two concurrent processes loading the same baseline and overwriting each other.
143143
- **Schedule JSON file-size cap** (`internal/schedule/store.go`) — `schedules.json` and `schedule-state.json` are rejected if larger than 10 MiB before being read into memory, preventing a tampered multi-gigabyte file from OOMing the scheduler.
144144
- **Default non-interactive policy is deny** (`internal/danger/classifier.go`, `cmd/odek/main.go`) — headless/CI/piped runs no longer auto-approve dangerous operations. Operators must explicitly set `non_interactive: "allow"` for unattended execution.
145-
- **`~/.odek` trust-anchor protection** (`internal/danger/classifier.go`, `cmd/odek/file_tool.go`) — generic file tools reject writes to `~/.odek/config.json`, `secrets.env`, `IDENTITY.md`, `skills/`, `sessions/`, `audit/`, `plans/`, `schedules.json`, `schedule-state.json`, `mcp_approvals.json`, `mcp_tool_approvals.json`, `restart.json`, `telegram.lock`, and related state files. These paths classify as `system_write` and must be modified through their dedicated subsystems.
145+
- **`~/.odek` trust-anchor protection** (`internal/danger/classifier.go`, `cmd/odek/file_tool.go`) — generic file tools reject writes to `~/.odek/config.json`, `secrets.env`, `IDENTITY.md`, `skills/`, `sessions/`, `audit/`, `plans/`, `schedules.json`, `schedule-state.json`, `mcp_approvals.json`, `mcp_tool_approvals.json`, `restart.json`, `telegram.lock`, and related state files. These paths classify as `system_write` and must be modified through their dedicated subsystems. Shell reads of these trust anchors are also escalated to `system_write`.
146146
- **Nonce'd tool-result delimiter** (`internal/loop/loop.go`) — the static `┌── TOOL RESULT: ... └── END TOOL RESULT: ...` delimiter is now unique per tool call via a random hex nonce embedded in both the opening and closing lines. A tool or MCP server can no longer forge the closing delimiter to break out of the data framing and inject instructions.
147147
- **`parallel_shell` context + process-group kill** (`cmd/odek/perf_tools.go`) — commands now run via `exec.CommandContext` bound to the agent context, in their own process group. Cancellation or timeout kills the whole group (negative PID), so `sh -c 'sleep 3600 &'` cannot leave orphaned children. Per-command timeouts are also capped at 30 minutes.
148148
- **`batch_patch` trusted-class propagation** (`cmd/odek/perf_tools.go`) — `batch_patch` now passes its cached `trustedClasses` to `CheckOperation`, matching `write_file` and `patch`. A trusted `local_write` class is honored across all patches in the batch instead of re-prompting per patch.
@@ -155,6 +155,8 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
155155
- **Telegram outbound media path allowlist** (`internal/telegram/media_path.go` + `internal/telegram/handler.go` + `internal/tool/send_message.go` + `cmd/odek/telegram.go`) — paths supplied to `MEDIA:...` prefixes or `send_message(file=...)` are resolved to an absolute path and verified against an allowlist (cwd, `~/.odek/media/`, system temp dir). `os.Lstat` rejects symlink final components and `filepath.EvalSymlinks` ensures the resolved path does not escape the allowlist, preventing prompt-injection-driven exfiltration of arbitrary files.
156156
- **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>` 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.
157157
- **Skill/episode untrusted wrapper** (`internal/loop/loop.go` + `odek.go`) — skill context 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.
158+
- **`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.
159+
- **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.
158160
- **Episode index session ID validation** (`internal/memory/episode_index.go` + `internal/session/session.go`) — `readAllSummaries` treats `index.json` as untrusted input and validates every `session_id` with `session.ValidateSessionID` before building the `filepath.Join(dir, sessionID+".md")` path. Invalid / traversal / separator-containing IDs are skipped with a warning, preventing a tampered episode index from pulling arbitrary files (e.g. `~/.odek/config.json`, `IDENTITY.md`) into the embedding space.
159161
- **Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc.
160162

cmd/odek/next_security_vulnerabilities_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ func TestServe_CSRF_AllowsLocalhostOrigin(t *testing.T) {
214214
func TestServe_StaticSecurityHeaders(t *testing.T) {
215215
req := httptest.NewRequest(http.MethodGet, "/", nil)
216216
rr := httptest.NewRecorder()
217-
handleStatic().ServeHTTP(rr, req)
217+
handleStatic("").ServeHTTP(rr, req)
218218

219219
if rr.Code != http.StatusOK {
220220
t.Fatalf("static handler returned %d", rr.Code)

cmd/odek/serve.go

Lines changed: 151 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package main
22

33
import (
44
"context"
5+
"crypto/rand"
56
"crypto/subtle"
67
"embed"
8+
"encoding/hex"
79
"encoding/json"
810
"fmt"
911
"net"
@@ -256,10 +258,23 @@ func serveCmd(args []string) error {
256258
resource.NewSessionResolver(filepath.Join(home, ".odek", "sessions")),
257259
)
258260

261+
// Per-instance CSRF token for browser-driven WebSocket connections. A
262+
// random token is issued at server start, injected into the served HTML,
263+
// and also delivered as a SameSite=Strict HttpOnly cookie. The /ws
264+
// handshake requires the token via cookie, header, or WebSocket
265+
// subprotocol, so a page served by another localhost port cannot open
266+
// an agent-controlling WebSocket.
267+
wsToken, err := newServeToken()
268+
if err != nil {
269+
return fmt.Errorf("CSRF token: %w", err)
270+
}
271+
259272
mux := http.NewServeMux()
260-
mux.HandleFunc("/", handleStatic())
273+
mux.HandleFunc("/", handleStatic(wsToken))
261274
mux.Handle("/ws", &golangws.Server{
262-
Handshake: wsHandshakeWithLimits,
275+
Handshake: func(cfg *golangws.Config, req *http.Request) error {
276+
return wsHandshakeWithLimits(cfg, req, wsToken)
277+
},
263278
Handler: func(conn *golangws.Conn) {
264279
handleWS(store, resourceReg, resolved, systemMessage, conn)
265280
},
@@ -550,13 +565,19 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
550565

551566
// ── WebSocket Types ────────────────────────────────────────────────────
552567

568+
type wsAttachment struct {
569+
Name string `json:"name"`
570+
Content string `json:"content"`
571+
}
572+
553573
type wsClientMsg struct {
554-
Type string `json:"type"`
555-
Content string `json:"content"`
556-
SessionID string `json:"session_id"`
557-
AuthToken string `json:"auth_token,omitempty"`
558-
Model string `json:"model,omitempty"`
559-
Thinking string `json:"thinking,omitempty"` // "enabled" | "" — per-query toggle
574+
Type string `json:"type"`
575+
Content string `json:"content"`
576+
SessionID string `json:"session_id"`
577+
AuthToken string `json:"auth_token,omitempty"`
578+
Model string `json:"model,omitempty"`
579+
Thinking string `json:"thinking,omitempty"` // "enabled" | "" — per-query toggle
580+
Attachments []wsAttachment `json:"attachments,omitempty"`
560581
}
561582

562583
// ── WebSocket Handler ──────────────────────────────────────────────────
@@ -773,7 +794,7 @@ func handleWS(store *session.Store, resources *resource.Registry, resolved confi
773794
// Derived from connCtx so a disconnect also aborts the running prompt.
774795
promptCtx, promptCancel := context.WithCancel(connCtx)
775796

776-
currentSession = handlePrompt(promptCtx, conn, store, resources, resolved, agent, currentSession, msg.Content, msg.SessionID, &sessionInputTokens, &sessionOutputTokens, promptCancel)
797+
currentSession = handlePrompt(promptCtx, conn, store, resources, resolved, agent, currentSession, msg, &sessionInputTokens, &sessionOutputTokens, promptCancel)
777798

778799
// Cancel the prompt context once the run is complete.
779800
promptCancel()
@@ -802,11 +823,13 @@ func handlePrompt(
802823
resolved config.ResolvedConfig,
803824
agent *odek.Agent,
804825
currSess *session.Session,
805-
prompt string,
806-
sessionID string,
826+
msg wsClientMsg,
807827
sessionInputTokens, sessionOutputTokens *int,
808828
promptCancel context.CancelFunc,
809829
) *session.Session {
830+
prompt := msg.Content
831+
sessionID := msg.SessionID
832+
810833
// Resolve @ references
811834
refs := resource.ParseRefs(prompt)
812835
resolvedRefs := make(map[string]string)
@@ -819,6 +842,40 @@ func handlePrompt(
819842
}
820843
enrichedPrompt := resource.ReplaceRefs(prompt, resolvedRefs)
821844

845+
// Web UI file attachments cross the browser trust boundary. Wrap each one
846+
// with the same nonce'd untrusted boundary used for tool output before
847+
// injecting them into the prompt, so a malicious attachment cannot be
848+
// mistaken for system instructions.
849+
if len(msg.Attachments) > 0 {
850+
const maxAttachmentBytes = 5 * 1024 * 1024
851+
const maxTotalAttachmentBytes = 10 * 1024 * 1024
852+
var total int
853+
var wrapped []string
854+
for _, att := range msg.Attachments {
855+
if att.Name == "" || att.Content == "" {
856+
continue
857+
}
858+
if len(att.Content) > maxAttachmentBytes {
859+
writeWSError(conn, "attachment too large: "+att.Name)
860+
return currSess
861+
}
862+
total += len(att.Content)
863+
if total > maxTotalAttachmentBytes {
864+
writeWSError(conn, "total attachment size exceeds 10 MB")
865+
return currSess
866+
}
867+
header := "--- " + att.Name + " ---\n"
868+
wrapped = append(wrapped, wrapUntrusted(ctx, "attachment:"+att.Name, header+att.Content))
869+
}
870+
if len(wrapped) > 0 {
871+
if enrichedPrompt != "" {
872+
enrichedPrompt = strings.Join(wrapped, "\n\n") + "\n\n" + enrichedPrompt
873+
} else {
874+
enrichedPrompt = strings.Join(wrapped, "\n\n")
875+
}
876+
}
877+
}
878+
822879
// Load or create session
823880
var sess *session.Session
824881
var err error
@@ -1071,6 +1128,9 @@ func (w *wsStreamWriter) Write(p []byte) (int, error) {
10711128
// approve dangerous tool calls. The default policy allows any port on
10721129
// localhost / 127.0.0.1 / [::1] and an empty Origin (curl, native
10731130
// clients). See IMPROVEMENTS_ROADMAP.md S-M1.
1131+
//
1132+
// Note: this check is now defense-in-depth. The primary CSRF protection is
1133+
// the per-instance wsToken validated by validateServeToken.
10741134
func checkLocalOrigin(_ *golangws.Config, req *http.Request) error {
10751135
origin := req.Header.Get("Origin")
10761136
if origin == "" {
@@ -1087,10 +1147,69 @@ func checkLocalOrigin(_ *golangws.Config, req *http.Request) error {
10871147
return fmt.Errorf("Origin %q not allowed (only localhost is accepted)", origin)
10881148
}
10891149

1090-
// wsHandshakeWithLimits wraps checkLocalOrigin with a per-IP upgrade rate limit
1091-
// and a global concurrent-connection semaphore. The semaphore is acquired before
1092-
// the WebSocket handshake completes and released when the handler exits.
1093-
func wsHandshakeWithLimits(_ *golangws.Config, req *http.Request) error {
1150+
const (
1151+
wsTokenCookieName = "odek_ws_token"
1152+
wsTokenHeaderName = "X-Odek-Ws-Token"
1153+
wsTokenProtocolPrefix = "odek."
1154+
)
1155+
1156+
// newServeToken generates a 256-bit random token used to authenticate
1157+
// browser WebSocket upgrades. The token is issued once per server process.
1158+
func newServeToken() (string, error) {
1159+
b := make([]byte, 32)
1160+
if _, err := rand.Read(b); err != nil {
1161+
return "", err
1162+
}
1163+
return hex.EncodeToString(b), nil
1164+
}
1165+
1166+
// validateServeToken verifies that the browser has the per-instance CSRF
1167+
// token. It accepts the token via:
1168+
// - the HttpOnly SameSite=Strict cookie set when serving index.html
1169+
// (automatic for legitimate same-origin pages),
1170+
// - an X-Odek-Ws-Token header (for non-browser clients), or
1171+
// - a WebSocket subprotocol of the form "odek.<token>" (for clients that
1172+
// can set Sec-WebSocket-Protocol).
1173+
func validateServeToken(cfg *golangws.Config, req *http.Request, token string) error {
1174+
if token == "" {
1175+
return fmt.Errorf("server token not configured")
1176+
}
1177+
1178+
// Cookie (browser same-origin / same-site requests).
1179+
if c, err := req.Cookie(wsTokenCookieName); err == nil && c.Value != "" {
1180+
if subtle.ConstantTimeCompare([]byte(c.Value), []byte(token)) == 1 {
1181+
return nil
1182+
}
1183+
}
1184+
1185+
// Explicit header (non-browser clients).
1186+
if h := req.Header.Get(wsTokenHeaderName); h != "" {
1187+
if subtle.ConstantTimeCompare([]byte(h), []byte(token)) == 1 {
1188+
return nil
1189+
}
1190+
}
1191+
1192+
// WebSocket subprotocol.
1193+
for _, p := range cfg.Protocol {
1194+
if strings.HasPrefix(p, wsTokenProtocolPrefix) {
1195+
got := strings.TrimPrefix(p, wsTokenProtocolPrefix)
1196+
if subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1 {
1197+
return nil
1198+
}
1199+
}
1200+
}
1201+
1202+
return fmt.Errorf("missing or invalid server token")
1203+
}
1204+
1205+
// wsHandshakeWithLimits validates the CSRF token and checks the origin, then
1206+
// applies a per-IP upgrade rate limit and acquires the global
1207+
// concurrent-connection semaphore. The semaphore is acquired before the
1208+
// WebSocket handshake completes and released when the handler exits.
1209+
func wsHandshakeWithLimits(cfg *golangws.Config, req *http.Request, token string) error {
1210+
if err := validateServeToken(cfg, req, token); err != nil {
1211+
return err
1212+
}
10941213
if err := checkLocalOrigin(nil, req); err != nil {
10951214
return err
10961215
}
@@ -1413,7 +1532,7 @@ var staticFiles = map[string][2]string{
14131532
"/app.js": {"ui/app.js", "application/javascript; charset=utf-8"},
14141533
}
14151534

1416-
func handleStatic() http.HandlerFunc {
1535+
func handleStatic(wsToken string) http.HandlerFunc {
14171536
return func(w http.ResponseWriter, r *http.Request) {
14181537
// Browsers auto-request favicon.ico — serve a minimal SVG inline.
14191538
if r.URL.Path == "/favicon.ico" {
@@ -1431,6 +1550,22 @@ func handleStatic() http.HandlerFunc {
14311550
http.Error(w, "not found", http.StatusNotFound)
14321551
return
14331552
}
1553+
1554+
// The HTML entry point gets the per-instance CSRF token both as a
1555+
// cookie (sent automatically on same-site WebSocket upgrades) and as a
1556+
// meta tag (read by app.js and sent as a WebSocket subprotocol).
1557+
if r.URL.Path == "/" && wsToken != "" {
1558+
http.SetCookie(w, &http.Cookie{
1559+
Name: wsTokenCookieName,
1560+
Value: wsToken,
1561+
Path: "/",
1562+
SameSite: http.SameSiteStrictMode,
1563+
HttpOnly: true,
1564+
// No explicit MaxAge/Expires so the cookie is a session cookie.
1565+
})
1566+
data = []byte(strings.Replace(string(data), "{{ODEK_WS_TOKEN}}", wsToken, 1))
1567+
}
1568+
14341569
w.Header().Set("Content-Type", entry[1])
14351570
w.Header().Set("X-Content-Type-Options", "nosniff")
14361571
w.Header().Set("Referrer-Policy", "no-referrer")

cmd/odek/serve_api_test.go

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -582,11 +582,7 @@ func TestServe_E2E_NoRepetitiveResponses(t *testing.T) {
582582
go serveOnListener(ln, mux)
583583
waitForHTTP(t, ln.Addr().String())
584584

585-
wsURL := "ws://" + ln.Addr().String() + "/ws"
586-
conn, err := golangws.Dial(wsURL, "", "http://localhost")
587-
if err != nil {
588-
t.Fatalf("Dial: %v", err)
589-
}
585+
conn := dialTestWS(t, ln.Addr().String())
590586
defer conn.Close()
591587

592588
send := func(content string) {
@@ -665,11 +661,7 @@ func TestServe_E2E_SessionMessagesStoredWithoutSystemInjections(t *testing.T) {
665661
go serveOnListener(ln, mux)
666662
waitForHTTP(t, ln.Addr().String())
667663

668-
wsURL := "ws://" + ln.Addr().String() + "/ws"
669-
conn, err := golangws.Dial(wsURL, "", "http://localhost")
670-
if err != nil {
671-
t.Fatalf("Dial: %v", err)
672-
}
664+
conn := dialTestWS(t, ln.Addr().String())
673665
defer conn.Close()
674666

675667
// Send one prompt, collect session ID.

cmd/odek/serve_approval_test.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,17 @@ func buildServeMuxPromptAll(t *testing.T, store *session.Store) (net.Listener, *
4444
resource.NewSessionResolver(filepath.Join(home, ".odek", "sessions")),
4545
)
4646

47+
wsToken, err := newServeToken()
48+
if err != nil {
49+
t.Fatalf("CSRF token: %v", err)
50+
}
51+
4752
mux := http.NewServeMux()
48-
mux.HandleFunc("/", handleStatic())
53+
mux.HandleFunc("/", handleStatic(wsToken))
4954
mux.Handle("/ws", &golangws.Server{
50-
Handshake: func(*golangws.Config, *http.Request) error { return nil },
55+
Handshake: func(cfg *golangws.Config, req *http.Request) error {
56+
return wsHandshakeWithLimits(cfg, req, wsToken)
57+
},
5158
Handler: func(conn *golangws.Conn) {
5259
handleWS(store, resourceReg, resolved, systemMessage, conn)
5360
},
@@ -85,11 +92,7 @@ func TestServe_E2E_ApprovalRoundTrip(t *testing.T) {
8592
go func() { _ = serveOnListener(ln, mux) }()
8693
waitForHTTP(t, ln.Addr().String())
8794

88-
wsURL := "ws://" + ln.Addr().String() + "/ws"
89-
conn, err := golangws.Dial(wsURL, "", "http://localhost")
90-
if err != nil {
91-
t.Fatalf("Dial(%q): %v", wsURL, err)
92-
}
95+
conn := dialTestWS(t, ln.Addr().String())
9396
defer conn.Close()
9497

9598
prompt := map[string]string{"type": "prompt", "content": "run a command"}

0 commit comments

Comments
 (0)