Skip to content

fix(agent-ui): stop html previews from escaping their iframe sandbox - #1938

Open
ulivz wants to merge 1 commit into
mainfrom
fix/agent-ui-iframe-sandbox-escape
Open

fix(agent-ui): stop html previews from escaping their iframe sandbox#1938
ulivz wants to merge 1 commit into
mainfrom
fix/agent-ui-iframe-sandbox-escape

Conversation

@ulivz

@ulivz ulivz commented Aug 1, 2026

Copy link
Copy Markdown
Member

Problem

multimodal/tarko/agent-ui renders model-authored HTML into iframes whose sandbox attribute contained both allow-scripts and allow-same-origin. Per the HTML spec that combination is not a sandbox at all whenever the framed content's origin resolves to the embedder's own origin — and srcdoc documents inherit the embedder's origin by definition. Chromium says so itself:

An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing.

Four frames were affected: the streaming HTML preview, the fullscreen HTML preview, and the two embed frames.

The preview path needs no user interaction. A file-writing tool call whose path ends in .html opens the workspace panel, and the panel switches itself to rendered mode, which hands the file's contents straight to the preview frame. Shared/replay pages are a second delivery surface.

Impact

Script inside such a frame is same-origin with the UI, so it can read and write parent.document, read the UI's localStorage, and issue same-origin requests to the agent server that serves the UI — using the victim's own browser context. Because the agent exposes tools that run commands, driving that API is the escalation path, which is why this is treated as a code-execution issue rather than "just" DOM access.

Measured in Chromium against a real HTTP origin, a srcDoc frame with the old attribute pair reported window.origin equal to the embedder's origin and then successfully read the embedder's document.title, read an embedder-scope JS global, wrote the embedder's document.title (verified on the embedder side, not self-reported), read the embedder's localStorage, and completed a same-origin fetch. In that configuration the frame's "own" storage and the embedder's storage are literally the same object — verified in both directions.

Fix

allow-same-origin means "let this document keep its own origin". Whether that is safe depends entirely on what its own origin is, so the two frame kinds are treated differently. The policy now lives in one place, src/common/constants/iframeSandbox.ts.

HTML previews (ThrottledHtmlRenderer, FullscreenModal) — always strict, allow-scripts only. Their content arrives via srcDoc and therefore inherits this page's origin; there is no legitimate need for a one-shot preview to keep origin-keyed state. Scripts still execute, now in an opaque origin.

Embed frames (EmbedFrameRenderer) — decided per URL by resolveEmbedFrameSandbox(src). A cross-origin http(s) target keeps allow-same-origin; a same-origin, relative, unparsable or non-http(s) URL (javascript:, data:, blob:, about:) gets the strict policy, and any parse failure falls through to strict. allow-forms allow-popups allow-modals is retained in both cases because embedded tools drive their own forms, popups and dialogs.

Keeping the flag for cross-origin targets is not a concession: it does not grant embedder access. The framed page keeps its own remote origin, which is still cross-origin to the UI, and parent access stays refused with SecurityError: ... Blocked a frame with origin "<remote origin>" from accessing a cross-origin frame. What it does preserve is the embedded tool's ability to use its own localStorage, sessionStorage, cookies and same-origin requests. Dropping it there would buy no protection and would break embedded tools outright — measured: SecurityError: ... The document is sandboxed and lacks the 'allow-same-origin' flag. for storage, TypeError: Failed to fetch plus a CORS rejection from origin 'null' for the tool's own-origin requests. Embed URLs also come from deployment-side web UI configuration rather than model output, so they are not on the zero-click path that motivates this change.

Streaming preview mechanism

The old streaming path had the parent write into the frame: iframeDoc.open()/write(), tempDiv.innerHTML for appended chunks, and a srcDoc fallback. An opaque origin makes contentDocument unreachable, so that mechanism had to go.

ThrottledHtmlRenderer now keeps two stacked frames and alternates them: incoming content goes into the hidden frame's srcDoc, and the frames swap visibility on that frame's load event, so a partially parsed or empty document is never shown. Streaming commits stay throttled to one per 200 ms as before, and a non-streaming change commits immediately. A guard covers the case where the hidden frame already holds byte-identical content, which would otherwise produce no load event and no swap.

Verification

Mechanism-level, before/after, same payload, only the sandbox attribute differing:

Probe allow-scripts allow-same-origin allow-scripts
frame window.origin embedder's origin null
read parent.document.title succeeded blocked
write parent.document.title succeeded, verified on embedder blocked, embedder title unchanged
read embedder JS global succeeded blocked
read embedder localStorage succeeded blocked
same-origin fetch 200 + body TypeError: Failed to fetch
scripts execute yes yes
Chromium sandbox-escape warning emitted absent

Verbatim after-fix errors: SecurityError: Failed to read a named property 'document' from 'Window': Blocked a frame with origin "null" from accessing a cross-origin frame. and, for storage, SecurityError: Failed to read the 'localStorage' property from 'Window': The document is sandboxed and lacks the 'allow-same-origin' flag. The origin "null" in those messages is the opaque origin taking effect. The parent side also confirms isolation: iframe.contentDocument now reads null, where before it returned a live document.

Component-level, driving the real components in a browser with fabricated props:

  • Streaming preview — 14 growing chunks: rendered sections grew strictly monotonically (0→12) with no regression in visible content. A 60 Hz sampler over the 13 s stream (781 samples, all 13 swaps covered) never observed zero visible frames or two visible frames, and the visible frame's content length never shrank. A 126-frame burst capture across every swap contains no blank frame (payload background present in all 126, non-zero painted text in all 126). Inline <script> in the previewed document still executes. Final srcdoc is byte-identical to the final input, and the resulting DOM is character-identical to what a plain srcDoc iframe produces from the same string. Throttling verified: 14 chunks at 40 ms produced 5 document loads, final content still complete.
  • Non-streaming preview — full content on screen 33 ms after commit, all sections, scripts ran, clean console.
  • Fullscreen preview — renders with sandbox="allow-scripts", all sections, scripts ran.
  • Embed frame — cross-origin target: attribute includes allow-same-origin, and the tool's localStorage, sessionStorage, cookies, fetch/XHR and WebSocket (correct Origin header) all work, while parent.document, parent.location and top.location remain blocked. Same-origin and relative targets: attribute excludes allow-same-origin and parent access is blocked. allow-forms, allow-popups and allow-modals verified working under both policies. The full URL matrix (http(s) cross-origin, protocol-relative, same-origin absolute, root-relative, path-relative, javascript:, data:, blob:, about:blank, file:, ws:, unparsable, empty) resolves as intended.

Build artifact, multimodal/tarko/agent-ui-builder/static/:

$ grep -rc "allow-same-origin" .   # -> no matches
$ grep -o "allow-scripts[a-z -]*" index.html | sort | uniq -c
      1 allow-scripts
      1 allow-scripts allow-forms allow-popups allow-modals
$ grep -o -E 'sandbox:"[^"]*"' index.html   # -> no inline literal sandbox strings

Minification hoists the two policies to module-level variables referenced by all four frames; allow-same-origin is appended at runtime only on the cross-origin embed branch. In the source tree the token now appears only in explanatory comments.

tsc --noEmit for the package: zero new errors against the base commit, and one pre-existing error removed (TS2551 Property 'srcDoc' does not exist on type 'HTMLIFrameElement', from the deleted imperative fallback). The 43 remaining errors are pre-existing and in untouched files. vitest on the repo is unchanged versus the base commit (same 10 pre-existing collection failures from unbuilt sibling packages; 700/700 individual tests pass). Prettier clean. This package has no tests or lint script of its own, so the security property rests on the browser measurements and the artifact check above.

Behaviour differences, stated plainly

Replacing a document is not identical to mutating one, and two differences are user-perceptible:

  • In-frame scroll position resets on each streaming commit. Measured: scrolled to y=1400 inside the frame, the next streaming chunk returns it to y=0, where the old append path held position. With the 200 ms throttle this can happen several times a second while a long HTML report streams in. The old code only preserved position for pure appends and reset on any other change; now every commit resets.
  • Sub-resources are re-requested once per commit (14 commits of a payload with one image produced 14 requests versus 1). Rendering is unaffected, but a heavy external asset can flash mid-stream.
  • The old code forced body { background-color: white } inside the frame after every write, overriding the previewed document's own stylesheet — that inline override is gone, so a document that sets a dark background now renders dark. Previously such a document could render white-on-white and be unreadable. The wrapper keeps its white background for documents that set none.
  • Wrapper height differs by 2 px, from the border now sitting inside the 100vh box.

Not in this change

  • No CSP is shipped with the UI, and embed URL schemes are not allow-listed at the source. Both would be worthwhile defence-in-depth follow-ups on top of this sandbox correction.
  • EmbedFrameRenderer has a pre-existing quirk unrelated to this fix: when it first renders with an empty src it returns early, so its ResizeObserver is never attached and a later non-empty src renders unscaled. Its isFullscreen branch is also currently unreachable; both of its frames read the same resolved policy, so the conditional applies consistently if that branch is ever wired up.
  • No sanitizer dependency was added. This corrects a permission boundary; it is not content sanitisation.

`allow-scripts` together with `allow-same-origin` voids the sandbox for content whose
origin resolves to this page. Preview frames carry model-authored HTML through `srcDoc`,
which inherits the embedder's origin, so that HTML could read and write `parent.document`,
read the UI's storage and issue same-origin requests to the local agent API. No user
interaction was needed: writing an `.html` file opens the panel and flips it to rendered
mode on its own.

Preview frames now run with `allow-scripts` alone, in an opaque origin. The parent can no
longer reach `contentDocument`, so streaming updates moved from writing into the frame's
DOM to alternating two frames' `srcDoc`, revealing the incoming document once it has
loaded.

Embedded tools keep `allow-same-origin` only when their URL is a cross-origin http(s)
target, where it preserves the tool's own origin without granting any reach into the UI.
Same-origin, relative, unparsable and non-http URLs get the strict policy.
@netlify

netlify Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploy Preview for agent-tars-docs ready!

Name Link
🔨 Latest commit 579cbaf
🔍 Latest deploy log https://app.netlify.com/projects/agent-tars-docs/deploys/6a6e65bd9bdfa30008a2cf0b
😎 Deploy Preview https://deploy-preview-1938--agent-tars-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploy Preview for tarko ready!

Name Link
🔨 Latest commit 579cbaf
🔍 Latest deploy log https://app.netlify.com/projects/tarko/deploys/6a6e65bdd71ef40008d2a6a0
😎 Deploy Preview https://deploy-preview-1938--tarko.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

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.

1 participant