diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go index e9122be..7d0c2cd 100644 --- a/internal/dashboard/dashboard.go +++ b/internal/dashboard/dashboard.go @@ -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:/localhost:, a different + // origin from the live iframe (localhost:), 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). @@ -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 diff --git a/internal/dashboard/liveview.go b/internal/dashboard/liveview.go index a0a7bfa..d139f9c 100644 --- a/internal/dashboard/liveview.go +++ b/internal/dashboard/liveview.go @@ -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/ — rooted at "/". upstreamPath := "/" + rest @@ -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) @@ -275,14 +275,31 @@ func liveProxyTo(w http.ResponseWriter, r *http.Request, container, id string, p // Set-Cookie's Path to / 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:) is framed by the DASHBOARD, which is a + // DIFFERENT origin (127.0.0.1:, or localhost: 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) diff --git a/internal/dashboard/liveview_integration_test.go b/internal/dashboard/liveview_integration_test.go index 63fef2c..37faa73 100644 --- a/internal/dashboard/liveview_integration_test.go +++ b/internal/dashboard/liveview_integration_test.go @@ -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) diff --git a/internal/dashboard/liveview_isolation_test.go b/internal/dashboard/liveview_isolation_test.go index d8f437d..21fe0ac 100644 --- a/internal/dashboard/liveview_isolation_test.go +++ b/internal/dashboard/liveview_isolation_test.go @@ -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:) 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)