From 26800b9b661c271598b7cc4fb542f97bb80f1826 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sun, 15 Feb 2026 10:53:01 +0000 Subject: [PATCH] Scope jobs and snapshots to a browser session An HttpOnly, SameSite=Lax session cookie is issued when a job is created. Job and snapshot routes return 404 to any other session, so indexed source is only readable by the browser that loaded it. --- indexer.go | 1 + main.go | 37 ++++++++++++++++++++++++++++++++++--- main_test.go | 41 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/indexer.go b/indexer.go index ee962fa..47df94c 100644 --- a/indexer.go +++ b/indexer.go @@ -46,6 +46,7 @@ type Edge struct { type Snapshot struct { ID, Name, Source string + Owner string // session that created the snapshot Files []FileRecord Edges []Edge TotalLines int diff --git a/main.go b/main.go index 046faf2..e62b5f2 100644 --- a/main.go +++ b/main.go @@ -39,6 +39,7 @@ type Job struct { Error *string `json:"error"` Warnings []string `json:"warnings"` root string + owner string // session that created the job } type Server struct { @@ -126,6 +127,7 @@ func (s *Server) createJob(w http.ResponseWriter, r *http.Request) { writeError(w, 400, "Invalid job request") return } + owner := sessionID(w, r) url := "" if request.Kind == "github" { if request.URL == nil { @@ -143,7 +145,7 @@ func (s *Server) createJob(w http.ResponseWriter, r *http.Request) { writeError(w, 500, err.Error()) return } - job := &Job{ID: id, SourceKind: request.Kind, Name: "Local codebase", Phase: "receiving", Message: "Waiting for files…", Warnings: []string{}, root: root} + job := &Job{ID: id, SourceKind: request.Kind, Name: "Local codebase", Phase: "receiving", Message: "Waiting for files…", Warnings: []string{}, root: root, owner: owner} if request.Name != nil { job.Name = *request.Name } @@ -174,6 +176,10 @@ func (s *Server) jobRoute(w http.ResponseWriter, r *http.Request, parts []string if len(parts) > 1 { action = parts[1] } + if job, ok := s.jobSnapshot(id); !ok || job.owner != sessionID(nil, r) { + writeError(w, 404, "Unknown job") + return + } switch { case r.Method == http.MethodGet && action == "": if job, ok := s.jobSnapshot(id); ok { @@ -247,7 +253,7 @@ func (s *Server) snapshotRoute(w http.ResponseWriter, r *http.Request, parts []s id, action := part(0), part(1) s.snapshotsMu.Lock() snapshot := s.snapshots[id] - if snapshot == nil { + if snapshot == nil || snapshot.Owner != sessionID(nil, r) { s.snapshotsMu.Unlock() writeError(w, 404, "Unknown snapshot") return @@ -508,7 +514,7 @@ func (s *Server) indexJob(id, root string) { } s.jobsMu.Lock() if job := s.jobs[id]; job != nil { - snapshot.Name, snapshot.Source = job.Name, job.SourceKind + snapshot.Name, snapshot.Source, snapshot.Owner = job.Name, job.SourceKind, job.owner } s.jobsMu.Unlock() snapshot.ID = uniqueID("snapshot") @@ -620,6 +626,31 @@ func safeRelativePath(value string) (string, bool) { return filepath.Join(clean...), true } +const sessionCookie = "codenav_session" + +// sessionID returns the caller's session, issuing a cookie when w is non-nil and none exists. +// Jobs and snapshots are only visible to the session that created them. Without w a +// missing session returns "", which never matches an owner. +func sessionID(w http.ResponseWriter, r *http.Request) string { + if cookie, err := r.Cookie(sessionCookie); err == nil && len(cookie.Value) >= 26 && len(cookie.Value) <= 64 { + return cookie.Value + } + if w == nil { + return "" + } + id := rand.Text() + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: id, + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", + SameSite: http.SameSiteLaxMode, + }) + r.AddCookie(&http.Cookie{Name: sessionCookie, Value: id}) // later lookups in this request see it + return id +} + // uniqueID is unguessable: IDs are the capability that grants access to a job or snapshot. func uniqueID(prefix string) string { return prefix + "-" + rand.Text() diff --git a/main_test.go b/main_test.go index 9448d0f..e283a5b 100644 --- a/main_test.go +++ b/main_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/http/cookiejar" "net/http/httptest" "os" "path/filepath" @@ -67,14 +68,21 @@ func TestPathValidation(t *testing.T) { } type client struct { - t *testing.T - url string + t *testing.T + url string + http *http.Client +} + +// stranger is a different browser session against the same server. +func (c client) stranger() client { + jar, _ := cookiejar.New(nil) + return client{c.t, c.url, &http.Client{Jar: jar}} } func (c client) do(method, path, body string) (int, []byte) { c.t.Helper() req, _ := http.NewRequest(method, c.url+path, strings.NewReader(body)) - res, err := http.DefaultClient.Do(req) + res, err := c.http.Do(req) if err != nil { c.t.Fatal(err) } @@ -100,7 +108,7 @@ func newTestServer(t *testing.T) (*Server, client) { server := NewServer(filepath.Join(t.TempDir(), "codenavigator")) ts := httptest.NewServer(server) t.Cleanup(ts.Close) - return server, client{t, ts.URL} + return server, client{t, ts.URL, nil}.stranger() } func TestLocalJobLifecycle(t *testing.T) { @@ -118,7 +126,7 @@ func TestLocalJobLifecycle(t *testing.T) { c.json("POST", "/api/jobs/"+created.JobID+"/files?path=..%2Fescape.js", "x", 400, nil) c.json("POST", "/api/jobs/"+created.JobID+"/commit", "", 202, nil) - res, err := http.Get(c.url + "/api/jobs/" + created.JobID + "/events") + res, err := c.http.Get(c.url + "/api/jobs/" + created.JobID + "/events") if err != nil { t.Fatal(err) } @@ -139,6 +147,12 @@ func TestLocalJobLifecycle(t *testing.T) { } snapshotURL := "/api/snapshots/" + *job.SnapshotID + other := c.stranger() + other.json("GET", "/api/jobs/"+created.JobID, "", 404, nil) + other.json("POST", "/api/jobs/"+created.JobID+"/files?path=evil.js", "x", 404, nil) + other.json("GET", snapshotURL+"/scene", "", 404, nil) + other.json("GET", snapshotURL+"/entities/1/source", "", 404, nil) + var summary map[string]any c.json("GET", snapshotURL, "", 200, &summary) if summary["fileCount"] != 2.0 || summary["references"] != 1.0 || summary["name"] != "demo" { @@ -255,3 +269,20 @@ func must[T any](v T, err error) T { } return v } + +func TestSessionCookieFlags(t *testing.T) { + _, c := newTestServer(t) + req, _ := http.NewRequest("POST", c.url+"/api/jobs", strings.NewReader(`{"kind":"local"}`)) + req.Header.Set("X-Forwarded-Proto", "https") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + cookie := res.Header.Get("Set-Cookie") + for _, want := range []string{sessionCookie + "=", "HttpOnly", "Secure", "SameSite=Lax", "Path=/"} { + if !strings.Contains(cookie, want) { + t.Errorf("Set-Cookie %q missing %q", cookie, want) + } + } +}