Skip to content

Same-origin run preview sandbox lets untrusted preview JS use the viewer session #93

Open

Description

@fx-kurumi

Summary

Run frontend preview is served on the studio origin at /preview/{sessionToken}/ and embedded in an iframe with sandbox tokens that include allow-scripts and allow-same-origin. Preview HTML/JS therefore shares the LoopForge origin with the logged-in studio. A viewer who opens Run → 前端预览 can have their authenticated session used for studio API actions via the parent window (and, when a preview backend is configured, via direct same-origin fetch because CSP connect-src then includes 'self').

HttpOnly on agentflow_user blocks document.cookie reads; it does not block credentialed same-origin requests initiated by script that shares the app origin.

Affected code (head c7114fca)

Location What the code does
web/app/runs.js In-app preview iframe: src = session URL; sandbox="allow-scripts allow-forms allow-modals allow-downloads allow-same-origin"
internal/preview/service.go PublicURL → {scheme}://{host}/preview/{token}/ on the app host (path hosting, not a separate preview origin)
internal/preview/path.go HTTPPathPrefix = "/preview/"
internal/preview/gateway.go Response CSP: sandbox … allow-same-origin; empty backend → connect-src 'none'; non-empty backend → connect-src 'self' <backendOrigin>
internal/preview/service.go CreateSession mints opaque token; returns PublicURL
internal/httpapi/run_preview.go POST /api/runs/:id/preview/session creates session for a ready preview
internal/httpapi/server.go previewHost() is mounted before userAccessAuth() so /preview/{token}/ is served by opaque token, not browser login
internal/httpapi/auth.go / #L322-L340 Cookie name agentflow_user, HttpOnly: true, Path: /
internal/httpapi/space_artifacts.go Contrast: space HTML artifacts use CSP sandbox allow-scripts allow-forms without allow-same-origin
docs/proposals/frontend-preview-delivery-lifecycle.md (§13.3) Design explicitly keeps path hosting + allow-same-origin for Vite ESM and documents the parent.fetch / studio-session cost

Conditions (threat model — not anonymous Internet)

This is not “any anonymous visitor gets a session.” Entry requires content control plus a logged-in viewer opening the in-app preview.

  1. Content producer (attacker capability): someone who can cause a Run preview archive to contain attacker-influenced HTML/JS. Typical paths on this product:
    • Project member who can enable preview on an SOP (workflowSnapshot.preview.enabled / sourcePath such as dist) and run it so the agent (or build step) writes static files that are later archived (archive_preview / ArchiveDir).
    • Anyone who can otherwise influence agent-built static output under the configured preview sourcePath before finalization.
  2. Victim: a signed-in studio user (cookie agentflow_user) who opens Run → 前端预览 so previewPaneHTML embeds /preview/{token}/ in the same-origin iframe with allow-scripts + allow-same-origin.
  3. Out of band for the parent-access path: “独立窗口” uses window.open(url,'_blank','noopener') (runs.js ~826) and is outside the parent access path. Direct fetch('/api/…') from the preview document still depends on CSP connect-src when a backend is set.

Anonymous users without a session token cannot mint preview sessions through /api/… under normal auth-required deployments; possessing a leaked opaque preview URL loads preview content, but session-riding against studio APIs as described here targets a logged-in viewer of the in-app iframe.

Call chain (analytical — not executed in this audit)

  1. SOP with preview enabled completes; runner/engine archives sourcePath into a tar.zst preview bundle (HTML/JS as produced in the workspace).
  2. Viewer calls POST /api/runs/:id/preview/session; server returns url = same-host /preview/{token}/ via PublicURL.
  3. Studio embeds that URL in an iframe with allow-scripts + allow-same-origin (runs.js:738).
  4. Preview document origin equals the LoopForge app origin (sandbox is not opaque).
  5. Parent path (always available for same-origin nested browsing with these sandbox flags): preview script can reach window.parent and invoke studio APIs with the viewer’s cookies, e.g. conceptually parent.fetch('/api/…', {credentials:'include', …}). CSP on the preview response does not constrain the parent document’s network access.
  6. Direct path (when preview backend URL is set): previewCSP sets connect-src 'self' <backend>, so the preview document itself may fetch('/api/…') with same-site cookies. When backend is empty, connect-src is 'none' (direct connect blocked); the parent path remains.

Not claimed here: a live browser capture, a successful token mint, or any packet capture. The above is inferred from static source and the project’s own design note at §13.3.

Impact

  • Session-equivalent actions as the viewing user (create personal API tokens, read/modify data that user can access through studio APIs), without reading document.cookie.
  • Cross-user impact inside a shared installation/project: one member’s crafted or agent-produced preview can target another member who only opens the preview tab.
  • Design trade-off is acknowledged upstream (Vite ESM vs opaque origin); space-artifact CSP shows a stricter pattern already used elsewhere in-tree.

Suggested fix

  1. Prefer a dedicated preview origin (separate host) so allow-same-origin cannot equal the app origin.
  2. Until then: never put the app API origin in preview connect-src as 'self'; keep API on a different origin than preview files.
  3. Treat framebust / parent nullification as defense-in-depth only (bypassable); not the primary control.
  4. Revisit whether Vite ESM can be served without same-origin sandbox (blob workers, opaque origin + absolute module map, or separate origin).
  5. Add a regression test that documents/fails closed on parent/API access attempts from preview active content.

Acceptance criteria

  • Preview active content cannot perform credentialed calls to the studio API as the viewing user (separate origin and/or CSP that never allows app 'self' API connect from preview).
  • In-app iframe embedding cannot reach window.parent app privileges from preview script (separate origin, or equivalent isolation).
  • Threat model for agent-built preview bundles updated; automated regression covers the isolation property.
  • Issue text and tests do not claim a browser PoC unless one is separately recorded.

Activity

  1. fx-kurumi commented on Nov 6, 2026

    @fx-kurumi
    CollaboratorAuthor

    Additional minimal sandbox result: Local Chromium observed parent DOM/function access with the exact product sandbox (allow-scripts, allow-same-origin); removing allow-same-origin blocked both with an opaque origin. This was a synthetic parent/iframe check only; no LoopForge API, session, token, or real preview content was tested.

  2. AlexXPZhu commented on Nov 11, 2026

    @AlexXPZhu

    I have submitted a fix in #187.

    Archived previews now require a dedicated hostname and explicit studio frame origins. Preview tokens are not served on the studio host, platform routes are blocked on the preview host, and studio hostnames cannot be used as preview backends. Resource restrictions also cover image/script requests, while keeping ES modules and the existing History API shim working.

    The PR includes a real Chromium regression with a signed viewer cookie, plus negative checks against the original production files. All local Go packages, frontend tests/build, vet and repository scans passed. Deployment configuration and compatibility changes are documented; no SQL migration or runner upgrade is required.

  3. Robert0622 commented on Nov 11, 2026

    @Robert0622
    Collaborator

    Triage update (follow-up on prior analysis)

    Status recap from our side: we already implemented a phase-1 fix matching the issue's own proposed mitigation (close the connect-src 'self' gap in internal/preview/gateway.go's previewCSP, so the preview CSP never includes Studio's own origin, even when a preview backend is configured). Tests (go test ./internal/preview ./internal/httpapi) passed, but we have not been able to push/merge it yet due to an unrelated infra issue (our automation account currently lacks push permission on this repo -- being resolved on our side, not a code problem).

    Re: the note that a fix was submitted in #187 ("isolate archived previews from studio sessions", dedicated hostname + explicit studio frame origins) -- thanks for working on this. We reviewed #187: it's still open/unmerged as of now, so we can't confirm yet that it fully closes this out end-to-end. It looks complementary to our CSP-only phase-1 mitigation (addresses the allow-same-origin + shared-origin root cause more directly via a dedicated hostname), but the original issue's second concern -- window.parent access, which CSP alone cannot block -- still needs the origin-isolation approach #187 is pursuing.

    Conclusion: confirmed valid security issue, not yet fully resolved. Recommend reviewing/merging #187 for the origin-isolation piece; we'll reconcile our CSP-only patch with it once push access is restored. No behavior-change claims until #187 lands and is verified against the acceptance criteria in the original report (no viewer-session API calls from preview content via fetch or window.parent).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions