Skip to content

Commit a0700ce

Browse files
authored
L9: validate session ID on write and detect embedded ID mismatch (#81)
- internal/session/session.go: saveLocked now validates sess.ID before building the destination path; Load now verifies the embedded ID matches the filename it was loaded from, blocking planted files with a traversal 'id' field from redirecting Append/Save. - internal/session/session_test.go: regression tests for malformed save IDs and embedded-ID mismatches on Load and Append. - docs/SECURITY.md + AGENTS.md: document L9 mitigation.
1 parent e134f01 commit a0700ce

4 files changed

Lines changed: 79 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
149149
- **WebSocket message-size cap** (`cmd/odek/serve.go`) — `odek serve` sets `MaxPayloadBytes` on every WebSocket connection so a local client cannot OOM the server with a huge frame.
150150
- **WebSocket approval relay non-blocking** (`cmd/odek/wsapprover.go`) — `HandleResponse` uses a non-blocking send on the pending response channel, so duplicate or late approval responses are dropped instead of blocking the WebSocket read goroutine and exhausting the global connection semaphore.
151151
- **Session file size cap** (`internal/session/session.go`) — session files larger than 32 MiB are rejected by `Load()` to prevent OOM from tampered or corrupted transcripts.
152+
- **Session ID write-path validation** (`internal/session/session.go`) — `saveLocked` validates `sess.ID` with `ValidateSessionID` before computing the destination path, and `Load` checks that the embedded ID matches the filename it was loaded from. A planted session file with an attacker-controlled `"id"` field can no longer redirect `Append`/`Save` to write outside the session directory.
152153
- **Skill file size cap** (`internal/skills/loader.go`) — `SKILL.md` files larger than 1 MiB are skipped so a malicious project cannot OOM the process at startup or bloat the system prompt.
153154
- **Serve sandbox default-on**`odek serve` enables `--sandbox` automatically unless `--no-sandbox` is passed.
154155
- **Sandbox volume confinement** (`internal/sandbox/sandbox.go`) — extra `--sandbox-volume` host paths must resolve to a location under the working directory, cannot contain `..` or symlink escapes, and cannot match sensitive prefixes such as `/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/home`, `/var`, `/run`, or `/var/run/docker.sock`.

docs/SECURITY.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,12 @@ Now:
655655
- `recordTurnAudit` scans `user` messages for untrusted wrappers as well as `tool` messages.
656656
- The divergence check receives the original, pre-enrichment user prompt, so injected resources are treated as novel when the agent acts on them.
657657

658+
### 39l. Session store write-path validates the embedded session ID
659+
660+
`internal/session/session.go` built the destination path from `sess.ID` without validating it, while `Load` only validated the filename requested by the caller. A planted session file (for example, dropped by a malicious local process or extracted from an archive) whose JSON contained `"id": "../config"` would cause the next `Append` or `Save` to write outside the session directory, such as overwriting `~/.odek/config.json`.
661+
662+
`saveLocked` now calls `ValidateSessionID` before computing the filesystem path, and `Load` checks that the ID inside the file matches the filename it was loaded from. Any mismatch aborts the operation instead of following the attacker-controlled embedded ID.
663+
658664
### 40. `/api/resources` result limit cap
659665

660666
The `/api/resources?q=...&limit=N` autocomplete endpoint previously accepted any positive `limit` value. It is now capped to 100 results both in the HTTP handler and in `Registry.Search`. This prevents a prompt-injected or attacker-forged request from forcing an unbounded directory walk and returning a multi-megabyte JSON response.

internal/session/session.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,14 @@ func (s *Store) Save(sess *Session) error {
311311
// read and write gets replaced with a regular file.
312312
// Also atomically updates the session index with the session's metadata.
313313
func (s *Store) saveLocked(sess *Session) error {
314+
// Reject malformed or traversal-bearing session IDs before the ID is used
315+
// to build a filesystem path. A planted session file with an embedded
316+
// "id":"../config" must not cause a subsequent Save/Append to overwrite
317+
// files outside the session directory.
318+
if err := ValidateSessionID(sess.ID); err != nil {
319+
return fmt.Errorf("session: refusing unsafe save: %w", err)
320+
}
321+
314322
// Redact secrets from all messages and the task label before writing to
315323
// disk. This is defense-in-depth: the loop engine already redacts tool
316324
// outputs, but this catches any secrets that slipped through
@@ -369,6 +377,12 @@ func (s *Store) Load(id string) (*Session, error) {
369377
if err := json.Unmarshal(data, &sess); err != nil {
370378
return nil, fmt.Errorf("session: parse %q: %w", id, err)
371379
}
380+
// The on-disk ID must match the filename it was loaded from. This prevents
381+
// a planted session file from redirecting a later Save/Append to a path
382+
// derived from an attacker-controlled embedded ID.
383+
if sess.ID != id {
384+
return nil, fmt.Errorf("session: load %q: ID mismatch (file contains %q)", id, sess.ID)
385+
}
372386
return &sess, nil
373387
}
374388

internal/session/session_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,64 @@ func TestStore_Delete_PathTraversalRejected(t *testing.T) {
774774
}
775775
}
776776

777+
// TestStore_Save_RejectMalformedID verifies that Save refuses to write a
778+
// session whose embedded ID contains path traversal.
779+
func TestStore_Save_RejectMalformedID(t *testing.T) {
780+
store := newTestStore(t)
781+
msgs := []llm.Message{{Role: "user", Content: "test"}}
782+
sess, _ := store.Create(msgs, "m", "test")
783+
784+
sess.ID = "../config"
785+
err := store.Save(sess)
786+
if err == nil {
787+
t.Fatal("Save() with traversal ID should return error")
788+
}
789+
if !strings.Contains(err.Error(), "invalid ID") {
790+
t.Errorf("error should mention 'invalid ID', got: %v", err)
791+
}
792+
}
793+
794+
// TestStore_Load_RejectEmbeddedIDMismatch verifies that Load detects a
795+
// session file whose embedded ID does not match its filename.
796+
func TestStore_Load_RejectEmbeddedIDMismatch(t *testing.T) {
797+
store := newTestStore(t)
798+
799+
// Plant a session file whose filename is benign but whose JSON ID is not.
800+
plantedID := "20260718-plant001"
801+
malicious := `{"id":"../config","created_at":"2026-07-18T00:00:00Z","updated_at":"2026-07-18T00:00:00Z","model":"m","turns":1,"task":"x","messages":[]}`
802+
if err := os.WriteFile(store.Path(plantedID), []byte(malicious), 0600); err != nil {
803+
t.Fatal(err)
804+
}
805+
806+
_, err := store.Load(plantedID)
807+
if err == nil {
808+
t.Fatal("Load() of mismatched embedded ID should return error")
809+
}
810+
if !strings.Contains(err.Error(), "ID mismatch") {
811+
t.Errorf("error should mention 'ID mismatch', got: %v", err)
812+
}
813+
}
814+
815+
// TestStore_Append_RejectEmbeddedIDMismatch verifies that Append refuses to
816+
// rewrite a planted session file whose embedded ID differs from the filename.
817+
func TestStore_Append_RejectEmbeddedIDMismatch(t *testing.T) {
818+
store := newTestStore(t)
819+
820+
plantedID := "20260718-plant002"
821+
malicious := `{"id":"../config","created_at":"2026-07-18T00:00:00Z","updated_at":"2026-07-18T00:00:00Z","model":"m","turns":1,"task":"x","messages":[]}`
822+
if err := os.WriteFile(store.Path(plantedID), []byte(malicious), 0600); err != nil {
823+
t.Fatal(err)
824+
}
825+
826+
err := store.Append(plantedID, []llm.Message{{Role: "user", Content: "more"}})
827+
if err == nil {
828+
t.Fatal("Append() to planted mismatched file should return error")
829+
}
830+
if !strings.Contains(err.Error(), "ID mismatch") {
831+
t.Errorf("error should mention 'ID mismatch', got: %v", err)
832+
}
833+
}
834+
777835
// ── Additional edge-case coverage ──────────────────────────────────────
778836

779837
func TestValidateSessionID_NullByte(t *testing.T) {

0 commit comments

Comments
 (0)