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 {
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+34
-3
@@ -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"
|
||||||
@@ -69,12 +70,19 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user