Skip to content

Commit 05080cd

Browse files
committed
v0.8.7: fix #5 — TOCTOU race in session.Append()
Fixes the symlink-swap TOCTOU (time-of-check-time-of-use) race in session.Append() between Load (read) and Save (write): - saveLocked(): temp-file + os.Rename for atomic writes — os.Rename does NOT follow symlinks, replacing the directory entry itself - Store.mu sync.Mutex serializes the full read-modify-write cycle in Append(), preventing concurrent-write data loss from interleaving 3 new tests: TestAppend_ConcurrentSafety, TestSave_AtomicWriteNoPartialFile, TestSave_SymlinkNotFollowed. All 22+ existing tests pass.
1 parent a6b4a90 commit 05080cd

2 files changed

Lines changed: 181 additions & 7 deletions

File tree

internal/session/session.go

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"path/filepath"
2424
"sort"
2525
"strings"
26+
"sync"
2627
"time"
2728

2829
"github.com/BackendStack21/kode/internal/llm"
@@ -49,6 +50,7 @@ type Session struct {
4950
// Operations are simple file reads/writes — no locking, no caching.
5051
type Store struct {
5152
dir string // e.g. /home/user/.kode/sessions/
53+
mu sync.Mutex
5254
}
5355

5456
// NewStore creates a session store rooted at ~/.kode/sessions/.
@@ -111,6 +113,14 @@ func (s *Store) path(id string) string {
111113
return filepath.Join(s.dir, id+".json")
112114
}
113115

116+
// Path returns the absolute filesystem path for a session file.
117+
// Exported for testing and debugging.
118+
func (s *Store) Path(id string) string { return s.path(id) }
119+
120+
// Dir returns the session store directory path.
121+
// Exported for testing and debugging.
122+
func (s *Store) Dir() string { return s.dir }
123+
114124
// idFromPath extracts the session ID from a filename like "20260518-abc123.json".
115125
func idFromPath(name string) string {
116126
return strings.TrimSuffix(name, ".json")
@@ -137,28 +147,54 @@ func (s *Store) Create(messages []llm.Message, model, task string) (*Session, er
137147
}
138148

139149
// Append adds new messages to an existing session, updates timestamps
140-
// and turn counts, and saves the result.
150+
// and turn counts, and saves the result atomically.
151+
// The full read-modify-write is serialized by s.mu to prevent both
152+
// concurrent-write data loss and symlink-swap TOCTOU attacks.
141153
func (s *Store) Append(id string, newMsgs []llm.Message) error {
154+
s.mu.Lock()
155+
defer s.mu.Unlock()
156+
142157
sess, err := s.Load(id)
143158
if err != nil {
144159
return err
145160
}
146161
sess.Messages = append(sess.Messages, newMsgs...)
147162
sess.UpdatedAt = time.Now().UTC()
148163
sess.Turns = countUserTurns(sess.Messages)
149-
return s.Save(sess)
164+
return s.saveLocked(sess)
150165
}
151166

152-
// Save writes a session to disk, overwriting any existing file with
153-
// the same ID. This is the single write path — all mutations (append,
154-
// edit, truncate, rename) go through Save().
167+
// Save writes a session to disk atomically using a temp-file + rename
168+
// strategy. This prevents:
169+
// - Partial writes from crashes (rename is atomic on POSIX)
170+
// - Symlink-following TOCTOU attacks (os.Rename replaces the
171+
// directory entry itself — it does NOT follow symlinks)
155172
func (s *Store) Save(sess *Session) error {
173+
s.mu.Lock()
174+
defer s.mu.Unlock()
175+
return s.saveLocked(sess)
176+
}
177+
178+
// saveLocked is the internal write path — caller must hold s.mu.
179+
// Writes to a temp file in the same directory, then atomically
180+
// renames over the target. os.Rename replaces the directory entry
181+
// without following symlinks, so a symlink swapped in between
182+
// read and write gets replaced with a regular file.
183+
func (s *Store) saveLocked(sess *Session) error {
156184
data, err := json.MarshalIndent(sess, "", " ")
157185
if err != nil {
158186
return fmt.Errorf("session: marshal: %w", err)
159187
}
160-
if err := os.WriteFile(s.path(sess.ID), data, 0600); err != nil {
161-
return fmt.Errorf("session: write: %w", err)
188+
189+
target := s.path(sess.ID)
190+
tmp := target + ".tmp"
191+
if err := os.WriteFile(tmp, data, 0600); err != nil {
192+
os.Remove(tmp)
193+
return fmt.Errorf("session: write tmp: %w", err)
194+
}
195+
if err := os.Rename(tmp, target); err != nil {
196+
os.Remove(tmp)
197+
return fmt.Errorf("session: rename: %w", err)
162198
}
163199
return nil
164200
}

internal/session/session_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package session
33
import (
44
"fmt"
55
"os"
6+
"path/filepath"
67
"strings"
78
"testing"
89
"time"
@@ -277,6 +278,143 @@ func TestSession_GetMessages_Empty(t *testing.T) {
277278
}
278279
}
279280

281+
// ── Security: TOCTOU Race (Findings #5) ────────────────────────────────
282+
//
283+
// Append() reads a session file, modifies it, then writes it back.
284+
// An attacker who swaps the file for a symlink between read and write
285+
// could redirect the write to an arbitrary path. We fix this with:
286+
// - Atomic temp-file + os.Rename (Rename does NOT follow symlinks)
287+
// - sync.Mutex to serialize concurrent read-modify-write in Append
288+
//
289+
// These tests verify the fix and guard against regression.
290+
291+
func TestAppend_ConcurrentSafety(t *testing.T) {
292+
// Two concurrent Appends to the same session should both be reflected
293+
// in the final file — no lost writes.
294+
store := newTestStore(t)
295+
sess, err := store.Create(
296+
[]llm.Message{{Role: "user", Content: "start"}},
297+
"test", "start",
298+
)
299+
if err != nil {
300+
t.Fatal(err)
301+
}
302+
303+
done := make(chan error, 2)
304+
appendMsg := func(content string) {
305+
done <- store.Append(sess.ID, []llm.Message{{Role: "user", Content: content}})
306+
}
307+
308+
go appendMsg("thread-a")
309+
go appendMsg("thread-b")
310+
311+
for i := 0; i < 2; i++ {
312+
if err := <-done; err != nil {
313+
t.Errorf("concurrent Append error: %v", err)
314+
}
315+
}
316+
317+
loaded, err := store.Load(sess.ID)
318+
if err != nil {
319+
t.Fatal(err)
320+
}
321+
if len(loaded.Messages) != 3 {
322+
t.Errorf("expected 3 messages (start + 2 appends), got %d", len(loaded.Messages))
323+
}
324+
}
325+
326+
func TestSave_AtomicWriteNoPartialFile(t *testing.T) {
327+
// Verify that Save produces a valid JSON file (not a temp file,
328+
// not a truncated file) by checking the file path directly.
329+
store := newTestStore(t)
330+
sess, err := store.Create(
331+
[]llm.Message{{Role: "user", Content: "data"}},
332+
"test", "data",
333+
)
334+
if err != nil {
335+
t.Fatal(err)
336+
}
337+
338+
// Read the file directly — should be valid JSON
339+
path := store.Path(sess.ID)
340+
data, err := os.ReadFile(path)
341+
if err != nil {
342+
t.Fatalf("reading session file: %v", err)
343+
}
344+
if !strings.Contains(string(data), `"id":`) {
345+
t.Error("saved file doesn't look like valid JSON")
346+
}
347+
348+
// No .tmp files should remain in the session directory
349+
entries, _ := os.ReadDir(store.Dir())
350+
for _, e := range entries {
351+
if strings.HasSuffix(e.Name(), ".tmp") {
352+
t.Errorf("stale temp file found: %s", e.Name())
353+
}
354+
}
355+
}
356+
357+
func TestSave_SymlinkNotFollowed(t *testing.T) {
358+
// If the target path is a symlink, Save should replace the symlink
359+
// (not follow it) — this is the TOCTOU defense.
360+
store := newTestStore(t)
361+
sess, err := store.Create(
362+
[]llm.Message{{Role: "user", Content: "original"}},
363+
"test", "original",
364+
)
365+
if err != nil {
366+
t.Fatal(err)
367+
}
368+
369+
// Create a decoy file
370+
decoyPath := filepath.Join(store.Dir(), "decoy.txt")
371+
if err := os.WriteFile(decoyPath, []byte("decoy"), 0644); err != nil {
372+
t.Fatal(err)
373+
}
374+
375+
// Replace session file with symlink to decoy
376+
realPath := store.Path(sess.ID)
377+
if err := os.Remove(realPath); err != nil {
378+
t.Fatal(err)
379+
}
380+
if err := os.Symlink("decoy.txt", realPath); err != nil {
381+
t.Fatal(err)
382+
}
383+
384+
// Save should NOT follow the symlink — it should replace it
385+
sess.Messages = append(sess.Messages, llm.Message{Role: "assistant", Content: "response"})
386+
if err := store.Save(sess); err != nil {
387+
t.Fatal(err)
388+
}
389+
390+
// After Save, the real path should be a regular file, not a symlink
391+
fi, err := os.Lstat(realPath)
392+
if err != nil {
393+
t.Fatal(err)
394+
}
395+
if fi.Mode()&os.ModeSymlink != 0 {
396+
t.Error("Save left a symlink in place — should have been replaced with a regular file")
397+
}
398+
399+
// Decoy file should still have its original content (intact)
400+
decoyData, err := os.ReadFile(decoyPath)
401+
if err != nil {
402+
t.Fatal(err)
403+
}
404+
if string(decoyData) != "decoy" {
405+
t.Errorf("decoy file was overwritten! content: %q", string(decoyData))
406+
}
407+
408+
// The session file should contain valid session JSON
409+
loaded, err := store.Load(sess.ID)
410+
if err != nil {
411+
t.Fatal(err)
412+
}
413+
if len(loaded.Messages) != 2 {
414+
t.Errorf("expected 2 messages, got %d", len(loaded.Messages))
415+
}
416+
}
417+
280418
func TestCountUserTurns(t *testing.T) {
281419
msgs := []llm.Message{
282420
{Role: "system", Content: ""},

0 commit comments

Comments
 (0)