Skip to content

fix(preview): send CSP on preview responses to close RCE (#2771)#2866

Open
Kaushik2003 wants to merge 1 commit into
AgentWrapper:mainfrom
Kaushik2003:fix/preview-csp-header
Open

fix(preview): send CSP on preview responses to close RCE (#2771)#2866
Kaushik2003 wants to merge 1 commit into
AgentWrapper:mainfrom
Kaushik2003:fix/preview-csp-header

Conversation

@Kaushik2003

Copy link
Copy Markdown

What

The browser-panel preview serves workspace Markdown and HTML as text/html with no Content-Security-Policy. On the legacy /api/v1/sessions/{id}/preview/files/ route the preview document is same-origin with the daemon's control API (http://127.0.0.1:<port>), so a <script> in a previewed file can call that API directly — turning "open the Browser tab on a repository" into arbitrary command execution.

This PR sends a restrictive CSP on every preview response, differentiated by content type so existing functionality is preserved. The header is set in the single shared serving path (serveWorkspacePreviewFile), so it covers both the legacy route and the newer isolated *.localhost preview origin.

Why

Fixes #2771.

The vulnerability

Nothing more than a file landing in the workspace — for example a freshly cloned repository opened for inspection — is required to reach the daemon's privileged API. The chain:

Step Behavior Location
1 The Markdown renderer passes raw HTML through unsanitized (WithUnsafe()) backend/internal/preview/markdown.go
2 The preview is served as text/html with no CSP backend/internal/httpd/controllers/sessions.go (serveWorkspacePreviewFile)
3 The newest .md in the workspace is auto-selected, so opening the preview is enough to load attacker-controlled content backend/internal/preview/entry.go (DiscoverEntry)
4 The loopback listener is unauthenticated, so the config write is reachable same-origin over the legacy route backend/internal/httpd/controllers/projects.go (PUT /projects/{id}/config)
5 postCreate reaches sh -c with cmd.Dir set to the workspace backend/internal/session_manager/manager.go (runPostCreate)

Concretely, script in a previewed file can fetch() PUT /api/v1/projects/{id}/config to set postCreate, which the session manager later executes via sh -c.

The preview loads through a top-level navigation (frontend/src/main/browser-view-host.ts, webContents.loadURL(...)), so an HTTP-response CSP header authoritatively governs the document and the browser blocks the fetch.

Root cause

The comment in markdown.go"preview content is workspace-local and agent-trusted" — conflates being in the workspace with being authorized to execute. Untrusted workspace content is served in a context that can reach a privileged API, with nothing instructing the browser to contain it.

How

The fix

Set a CSP header in serveWorkspacePreviewFile, the one function through which both the legacy /preview/files/ route and the isolated preview origin serve content, with a policy chosen per content type. The load-bearing directive is connect-src 'none': the exploit requires a scripted fetch/XHR to the API, and because the config endpoint is PUT+JSON a <form> cannot forge the request either.

Two policies are defined:

// Rendered Markdown: a document needs neither scripts nor network, so lock both
// down while still allowing the inline stylesheet and images/badges.
const previewMarkdownCSP = "default-src 'none'; img-src * data:; style-src 'unsafe-inline'; font-src * data:; connect-src 'none'; base-uri 'none'; form-action 'none'"

// Raw files served verbatim (incl. .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 API.
const previewFileCSP = "connect-src 'none'; form-action 'none'"

They are applied in the two branches of serveWorkspacePreviewFile:

// raw / built-app files (the .html path that cannot be sanitized)
if !previewutil.IsMarkdownPath(clean) {
    w.Header().Set("Content-Security-Policy", previewFileCSP)
    http.ServeContent(w, r, info.Name(), info.ModTime(), file)
    return
}
// ... rendered Markdown ...
w.Header().Set("Content-Security-Policy", previewMarkdownCSP)
w.Header().Set("Content-Type", "text/html; charset=utf-8")

The pre-existing //nolint:gosec on the Markdown write is retained, but its justification is updated to cite the CSP rather than the "agent-trusted" assumption that no longer holds.

Why CSP rather than the alternatives

  • Sanitizing the Markdown (dropping WithUnsafe()) helps only the Markdown path and does nothing for raw .html, which is served verbatim. CSP covers both.
  • Validating postCreate is not viable: running arbitrary shell is that field's intended behavior, so there is no "malicious value" to reject.

Design notes and trade-offs

  • Two policies, not one. The raw-file policy is intentionally weaker (no default-src; scripts still run) so a built static app renders. Containment there rests on connect-src 'none' + form-action 'none': untrusted HTML may execute in-page but cannot reach the loopback API.
  • img-src * (rather than 'self' data:) preserves README badges and remote images. The trade-off is that a previewed file can still issue image GETs, an IP-leak channel — not the reported RCE, and tightenable later.
  • The isolated origin also receives connect-src 'none'. Because the header lives in the shared serving path, it applies uniformly to the *.localhost origin as well. This is a deliberate defense-in-depth choice; if maintainers would prefer to let a built app fetch its own data on its isolated origin, the raw-file policy can be relaxed on that path specifically.

Relationship to recent preview work

  • Isolated *.localhost preview origins (Resolve root relative assets in static previews #2818) already move preview off the API's origin. This change is complementary: the legacy /preview/files/ route is still same-origin with the API and still needs the header, and on the isolated origin the CSP is defense-in-depth. Setting it in the shared serving path covers both with one change.
  • The workspace-confined OpenWorkspaceFile (OpenRoot) path already blocks the in-workspace symlink escape, so that concern is not part of this PR.

Compatibility

Use case Effect
Markdown docs / READMEs (incl. remote images & badges) Unchanged (img-src * data:)
Rendered-document styling Unchanged (style-src 'unsafe-inline')
Built static app preview — rendering Unchanged (scripts/styles/images still load)
Built app that fetches its own data at runtime in preview Blocked by connect-src 'none' — the one behavioral change
Agent spawn / postCreate / dev-server preview Unchanged

Follow-up (out of scope)

  • A repo-trust model for the paths that inherently run repository code (agent spawn, postCreate) — the remaining defense-in-depth idea, separate from this fix.

Related work

Testing

From backend/:

  • go build ./... — compiles.
  • go test ./internal/httpd/controllers/ — passes, including the new TestSessionsAPI_PreviewSendsCSP, which asserts the CSP header on both the Markdown and raw-file preview responses (and fails without this change).
  • go vet ./internal/httpd/controllers/ — clean.
  • golangci-lint run on the changed package — 0 issues.

Checklist

  • Branched from main (or continuing an existing PR branch)
  • One focused change; links the related issue when applicable
  • Follows AGENTS.md conventions and PR hygiene
  • Tests added/updated for user-visible behavior where it makes sense
  • Relevant CI checks pass for the area touched (go, frontend, etc.)

…r#2771)

The preview serves workspace Markdown/HTML as text/html with no CSP. Over the
legacy /preview/files/ route the document is same-origin with the
unauthenticated loopback control API, so a <script> in a previewed file can
fetch() PUT /api/v1/projects/{id}/config to set postCreate, which the session
manager later runs via sh -c — arbitrary command execution from merely opening
the Browser tab on an unvetted repo.

Set a Content-Security-Policy in the shared serveWorkspacePreviewFile path, so
it covers both the legacy route and the isolated *.localhost origin:

- rendered Markdown: default-src 'none' (+ image/style/font allowances) — a
  document needs no scripts or network;
- raw/built-app files (incl. .html, which cannot be sanitized): connect-src
  'none' + form-action 'none', so scripts still render the page but cannot
  reach the loopback API.

connect-src 'none' is load-bearing: the exploit needs a scripted fetch/XHR, and
the config endpoint is PUT+JSON so a form cannot forge it. Documents and
built-app rendering are unaffected; only a preview page's own runtime network
fetch is blocked.

Adds a regression test asserting the CSP on the Markdown and raw-file paths.
@somewherelostt

Copy link
Copy Markdown
Collaborator

Thanks for contributing to Agent Orchestrator.

This PR is being picked up by the current external contributor on-call pair:

If someone is already working on this, please continue as usual.
The on-call pair is added for visibility, tracking, and support, not to take over the work.
If you need help with review, direction, reproduction, or next steps, please tag @illegalcall and @Pulkit7070 here.

For faster context or live questions, you can also join the AO Discord.

Join the session here:
https://discord.gg/H6ZDcUXmq

Come by if you want to see what is being built, ask questions, or just hang around with the community.

// .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'"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This raw-file policy is bypassable on the legacy same-origin /preview/files/ route, so it does not fully deliver "page script cannot reach the loopback control API" for raw .html.

connect-src 'none' only constrains fetch/XHR issued from this document's own realm. It has no frame-src/child-src/object-src/worker-src, and there is no default-src to backstop them. On the legacy route the preview is same-origin with the API (http://127.0.0.1:<port>), and the daemon sets no global X-Frame-Options/frame-ancestors (nothing in internal/httpd sets them). So a malicious index.html served under this policy (scripts run) can reach the sink through a nested same-origin iframe:

const f = document.createElement('iframe');
f.src = '/api/v1/health';        // any same-origin API doc; its response carries NO CSP
f.onload = () => f.contentWindow.fetch('/api/v1/projects/ID/config', {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: '{"postCreate":"..."}',
});
document.body.appendChild(f);

The fetch executes in the child realm, whose response has no CSP, so the parent's connect-src 'none' does not apply. (An about:blank iframe inherits the parent policy and would be blocked, but a navigated same-origin document gets its own empty policy, which is the hole.) The Markdown policy is unaffected because default-src 'none' implies frame-src 'none'.

Suggested fix: add frame-src 'none'; child-src 'none'; object-src 'none'; worker-src 'none' to previewFileCSP (or use default-src 'none' and re-allow only script-src/style-src/img-src/font-src). Note this may restrict a built app that legitimately uses iframes/workers, so it is a real trade-off worth calling out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(preview): markdown preview served same-origin with no CSP enables RCE via postCreate config write

4 participants