diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 5bdf597f27..a5dac19452 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -183,6 +183,19 @@ func (c *SessionsController) preview(w http.ResponseWriter, r *http.Request) { envelope.WriteJSON(w, http.StatusOK, res) } +// previewMarkdownCSP hardens rendered-Markdown previews: no scripts and no +// network access (a rendered document needs neither), while still allowing the +// inline stylesheet and images/badges so the document renders normally. This +// severs the "previewed file runs a script that calls the loopback API" chain +// (issue #2771) for the Markdown path at zero cost to document rendering. +const previewMarkdownCSP = "default-src 'none'; img-src * data:; style-src 'unsafe-inline'; font-src * data:; connect-src 'none'; base-uri 'none'; form-action 'none'" + +// previewFileCSP applies to raw workspace files served verbatim (including +// .html, which cannot be sanitized). Scripts/styles/images still load so a +// built static app renders, but connect-src 'none' blocks fetch/XHR/WebSocket +// so page script cannot reach the loopback control API (issue #2771). +const previewFileCSP = "connect-src 'none'; form-action 'none'" + func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request) { if c.Svc == nil { apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{sessionId}/preview/files/*") @@ -270,6 +283,7 @@ func (c *SessionsController) serveWorkspacePreviewFile(w http.ResponseWriter, r } defer func() { _ = file.Close() }() if !previewutil.IsMarkdownPath(clean) { + w.Header().Set("Content-Security-Policy", previewFileCSP) http.ServeContent(w, r, info.Name(), info.ModTime(), file) return } @@ -284,9 +298,10 @@ func (c *SessionsController) serveWorkspacePreviewFile(w http.ResponseWriter, r envelope.WriteError(w, r, err) return } + w.Header().Set("Content-Security-Policy", previewMarkdownCSP) w.Header().Set("Content-Type", "text/html; charset=utf-8") if r.Method != http.MethodHead { - _, _ = w.Write(rendered) //nolint:gosec // G705: preview content is workspace-local and agent-trusted + _, _ = w.Write(rendered) //nolint:gosec // G705: raw-HTML passthrough is contained by the restrictive CSP set above (default-src 'none'; connect-src 'none'), so previewed content cannot reach the loopback API — see issue #2771 } } diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index ab65d3ce8c..aa7039a8f9 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -464,6 +464,42 @@ func TestSessionsAPI_PreviewRejectsSessionIDTooLongForHostname(t *testing.T) { assertErrorCode(t, body, status, http.StatusUnprocessableEntity, "PREVIEW_SESSION_ID_UNSUPPORTED") } +func TestSessionsAPI_PreviewSendsCSP(t *testing.T) { + svc := newFakeSessionService() + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "README.md"), + []byte("# Hi\n\n"), 0o644); err != nil { + t.Fatalf("write md: %v", err) + } + if err := os.WriteFile(filepath.Join(workspace, "page.html"), + []byte("

hi

"), 0o644); err != nil { + t.Fatalf("write html: %v", err) + } + s := svc.sessions["ao-1"] + s.Metadata = domain.SessionMetadata{WorkspacePath: workspace} + svc.sessions["ao-1"] = s + srv := newSessionTestServer(t, svc) + + // Markdown path: strict CSP (no scripts, no network). + _, status, headers := doRequest(t, srv, "GET", "/api/v1/sessions/ao-1/preview/files/README.md", "") + if status != http.StatusOK { + t.Fatalf("md preview = %d, want 200", status) + } + if csp := headers.Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") || + !strings.Contains(csp, "connect-src 'none'") { + t.Fatalf("markdown CSP = %q, want default-src 'none' + connect-src 'none'", csp) + } + + // Raw file path: connect-src 'none' cuts the API-reach vector. + _, status, headers = doRequest(t, srv, "GET", "/api/v1/sessions/ao-1/preview/files/page.html", "") + if status != http.StatusOK { + t.Fatalf("html preview = %d, want 200", status) + } + if csp := headers.Get("Content-Security-Policy"); !strings.Contains(csp, "connect-src 'none'") { + t.Fatalf("html CSP = %q, want connect-src 'none'", csp) + } +} + func TestSessionsAPI_SetPreviewExplicitURLPersists(t *testing.T) { svc := newFakeSessionService() srv := newSessionTestServer(t, svc)