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.
This commit is contained in:
Andy Dixon
2026-02-15 10:53:01 +00:00
parent d8d4a77ba7
commit 26800b9b66
3 changed files with 71 additions and 8 deletions
+1
View File
@@ -46,6 +46,7 @@ type Edge struct {
type Snapshot struct { type Snapshot struct {
ID, Name, Source string ID, Name, Source string
Owner string // session that created the snapshot
Files []FileRecord Files []FileRecord
Edges []Edge Edges []Edge
TotalLines int TotalLines int
+34 -3
View File
@@ -39,6 +39,7 @@ type Job struct {
Error *string `json:"error"` Error *string `json:"error"`
Warnings []string `json:"warnings"` Warnings []string `json:"warnings"`
root string root string
owner string // session that created the job
} }
type Server struct { type Server struct {
@@ -126,6 +127,7 @@ func (s *Server) createJob(w http.ResponseWriter, r *http.Request) {
writeError(w, 400, "Invalid job request") writeError(w, 400, "Invalid job request")
return return
} }
owner := sessionID(w, r)
url := "" url := ""
if request.Kind == "github" { if request.Kind == "github" {
if request.URL == nil { if request.URL == nil {
@@ -143,7 +145,7 @@ func (s *Server) createJob(w http.ResponseWriter, r *http.Request) {
writeError(w, 500, err.Error()) writeError(w, 500, err.Error())
return 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 { if request.Name != nil {
job.Name = *request.Name job.Name = *request.Name
} }
@@ -174,6 +176,10 @@ func (s *Server) jobRoute(w http.ResponseWriter, r *http.Request, parts []string
if len(parts) > 1 { if len(parts) > 1 {
action = parts[1] action = parts[1]
} }
if job, ok := s.jobSnapshot(id); !ok || job.owner != sessionID(nil, r) {
writeError(w, 404, "Unknown job")
return
}
switch { switch {
case r.Method == http.MethodGet && action == "": case r.Method == http.MethodGet && action == "":
if job, ok := s.jobSnapshot(id); ok { 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) id, action := part(0), part(1)
s.snapshotsMu.Lock() s.snapshotsMu.Lock()
snapshot := s.snapshots[id] snapshot := s.snapshots[id]
if snapshot == nil { if snapshot == nil || snapshot.Owner != sessionID(nil, r) {
s.snapshotsMu.Unlock() s.snapshotsMu.Unlock()
writeError(w, 404, "Unknown snapshot") writeError(w, 404, "Unknown snapshot")
return return
@@ -508,7 +514,7 @@ func (s *Server) indexJob(id, root string) {
} }
s.jobsMu.Lock() s.jobsMu.Lock()
if job := s.jobs[id]; job != nil { 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() s.jobsMu.Unlock()
snapshot.ID = uniqueID("snapshot") snapshot.ID = uniqueID("snapshot")
@@ -620,6 +626,31 @@ func safeRelativePath(value string) (string, bool) {
return filepath.Join(clean...), true 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. // uniqueID is unguessable: IDs are the capability that grants access to a job or snapshot.
func uniqueID(prefix string) string { func uniqueID(prefix string) string {
return prefix + "-" + rand.Text() return prefix + "-" + rand.Text()
+36 -5
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
@@ -67,14 +68,21 @@ func TestPathValidation(t *testing.T) {
} }
type client struct { type client struct {
t *testing.T t *testing.T
url string 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) { func (c client) do(method, path, body string) (int, []byte) {
c.t.Helper() c.t.Helper()
req, _ := http.NewRequest(method, c.url+path, strings.NewReader(body)) 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 { if err != nil {
c.t.Fatal(err) c.t.Fatal(err)
} }
@@ -100,7 +108,7 @@ func newTestServer(t *testing.T) (*Server, client) {
server := NewServer(filepath.Join(t.TempDir(), "codenavigator")) server := NewServer(filepath.Join(t.TempDir(), "codenavigator"))
ts := httptest.NewServer(server) ts := httptest.NewServer(server)
t.Cleanup(ts.Close) t.Cleanup(ts.Close)
return server, client{t, ts.URL} return server, client{t, ts.URL, nil}.stranger()
} }
func TestLocalJobLifecycle(t *testing.T) { 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+"/files?path=..%2Fescape.js", "x", 400, nil)
c.json("POST", "/api/jobs/"+created.JobID+"/commit", "", 202, 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -139,6 +147,12 @@ func TestLocalJobLifecycle(t *testing.T) {
} }
snapshotURL := "/api/snapshots/" + *job.SnapshotID 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 var summary map[string]any
c.json("GET", snapshotURL, "", 200, &summary) c.json("GET", snapshotURL, "", 200, &summary)
if summary["fileCount"] != 2.0 || summary["references"] != 1.0 || summary["name"] != "demo" { 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 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)
}
}
}