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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+34
-3
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -69,12 +70,19 @@ func TestPathValidation(t *testing.T) {
|
||||
type client struct {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user