Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/dashboard/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,12 @@ type dashboardServer struct {
// token/apiToken (which are never accepted there). See liveview_server.go.
livePort int
liveToken string
// dashPort is the dashboard's own listen port. Needed so the live listener can
// name the dashboard's ORIGIN in the framed app's frame-ancestors CSP — the
// parent (dashboard) is 127.0.0.1:<dashPort>/localhost:<dashPort>, a different
// origin from the live iframe (localhost:<livePort>), so `'self'` alone would
// (and did) block the embed → white screen.
dashPort int
// bootID is a fresh random value each time the dashboard daemon starts. It is
// surfaced in /status so the browser can tell when the server has restarted
// and drop stale per-project UI state (e.g. mute prefs keyed by project id).
Expand Down Expand Up @@ -1616,6 +1622,7 @@ func CmdDashboardServe(args []string) error {
}
server.livePort = *livePort
server.liveToken = *liveToken
server.dashPort = *port
server.startLogRetention() // prune app_logs on start + hourly
server.startConvRetention() // prune conversations.db on start + hourly
server.startSandboxConvTail() // mirror running sandboxes' own Claude transcripts
Expand Down
27 changes: 22 additions & 5 deletions internal/dashboard/liveview.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,13 @@ func (d *dashboardServer) handleLiveProxy(w http.ResponseWriter, r *http.Request
return
}

liveProxyTo(w, r, container, id, port, rest)
liveProxyTo(w, r, container, id, port, rest, d.dashPort)
}

// liveProxyTo builds and runs the reverse-proxy for one request. Split out from
// handleLiveProxy (which resolves id→workspace→container) so it can be tested
// against a container name directly.
func liveProxyTo(w http.ResponseWriter, r *http.Request, container, id string, port int, rest string) {
func liveProxyTo(w http.ResponseWriter, r *http.Request, container, id string, port int, rest string, dashPort int) {
// The path the app sees is everything after /live/<port> — rooted at "/".
upstreamPath := "/" + rest

Expand Down Expand Up @@ -250,7 +250,7 @@ func liveProxyTo(w http.ResponseWriter, r *http.Request, container, id string, p
ResponseHeaderTimeout: 30 * time.Second,
},
ModifyResponse: func(resp *http.Response) error {
return hardenLiveResponse(resp, fmt.Sprintf("/p/%s/live/%d", id, port), port)
return hardenLiveResponse(resp, fmt.Sprintf("/p/%s/live/%d", id, port), port, dashPort)
},
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, e error) {
http.Error(w, fmt.Sprintf("live view: could not reach the app on port %d (%v)", port, e), http.StatusBadGateway)
Expand All @@ -275,14 +275,31 @@ func liveProxyTo(w http.ResponseWriter, r *http.Request, container, id string, p
// Set-Cookie's Path to <prefix>/ and strip any Domain, so project A's session
// rides only project A's path. (We used to DELETE Set-Cookie — that's why login
// never persisted.)
func hardenLiveResponse(resp *http.Response, prefix string, port int) error {
// liveFrameAncestors builds the frame-ancestors CSP for the framed app: the
// dashboard's origins (127.0.0.1 and localhost at dashPort) may embed it, nobody
// else. If dashPort is unset (shouldn't happen in a served daemon), fall back to
// allowing loopback framing so Live View isn't dead.
func liveFrameAncestors(dashPort int) string {
if dashPort == 0 {
return "frame-ancestors http://127.0.0.1:* http://localhost:*"
}
return fmt.Sprintf("frame-ancestors http://127.0.0.1:%d http://localhost:%d", dashPort, dashPort)
}

func hardenLiveResponse(resp *http.Response, prefix string, port, dashPort int) error {
h := resp.Header
// The app's own anti-framing headers would block our legitimate embed; drop
// them and assert our own frame-ancestors policy.
h.Del("X-Frame-Options")
h.Del("Content-Security-Policy")
h.Del("Content-Security-Policy-Report-Only")
h.Set("Content-Security-Policy", "frame-ancestors 'self'")
// The iframe (localhost:<livePort>) is framed by the DASHBOARD, which is a
// DIFFERENT origin (127.0.0.1:<dashPort>, or localhost:<dashPort> if opened
// that way). `frame-ancestors 'self'` would only allow the iframe's own origin
// → the browser refuses the cross-origin embed → white screen. Name the
// dashboard's actual origins instead (both loopback hosts, since either may be
// how the user opened it).
h.Set("Content-Security-Policy", liveFrameAncestors(dashPort))
// Path-scope the app's cookies to this project's mount (see doc above).
rewriteLiveSetCookies(h, prefix)

Expand Down
2 changes: 1 addition & 1 deletion internal/dashboard/liveview_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func TestLiveProxyAgainstContainer(t *testing.T) {

req := httptest.NewRequest("GET", "/p/x/live/"+port+"/lv.txt", nil)
rec := httptest.NewRecorder()
liveProxyTo(rec, req, container, "x", p, "lv.txt")
liveProxyTo(rec, req, container, "x", p, "lv.txt", 7777)

res := rec.Result()
body, _ := io.ReadAll(res.Body)
Expand Down
10 changes: 6 additions & 4 deletions internal/dashboard/liveview_isolation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,20 @@ func TestHardenLiveResponse(t *testing.T) {
resp.Header.Set("Content-Security-Policy-Report-Only", "frame-ancestors 'none'")
resp.Header.Add("Set-Cookie", "session=abc; Path=/")

if err := hardenLiveResponse(resp, "/p/abc/live/3000", 3000); err != nil {
if err := hardenLiveResponse(resp, "/p/abc/live/3000", 3000, 7777); err != nil {
t.Fatalf("hardenLiveResponse: %v", err)
}

h := resp.Header
// The app's anti-framing headers must be gone (else the browser refuses our
// legitimate embed), replaced by our own frame-ancestors 'self'.
// legitimate embed), replaced by a frame-ancestors naming the DASHBOARD's
// origins — the parent (127.0.0.1/localhost:<dashPort>) is a different origin
// from the live iframe, so `'self'` would block the embed → white screen.
if got := h.Get("X-Frame-Options"); got != "" {
t.Errorf("X-Frame-Options should be removed, got %q", got)
}
if got := h.Get("Content-Security-Policy"); got != "frame-ancestors 'self'" {
t.Errorf("CSP = %q, want frame-ancestors 'self'", got)
if got := h.Get("Content-Security-Policy"); got != "frame-ancestors http://127.0.0.1:7777 http://localhost:7777" {
t.Errorf("CSP = %q, want the dashboard's frame-ancestors origins", got)
}
if got := h.Get("Content-Security-Policy-Report-Only"); got != "" {
t.Errorf("report-only CSP should be removed, got %q", got)
Expand Down
Loading