The Sandbox That Trusted Its Parent
An AI-assistant sandbox validated no message origin and set no frame controls, letting any page run attacker code on its origin. Here is how, and the fix.
The setup
The target was an insurtech company running an internal AI-assistant app for its own teams. The assistant does something increasingly common: it executes code on the user’s behalf, and to do that safely it runs that code inside a sandboxed iframe. The idea is sound - untrusted or model-generated code goes into an isolated frame so it cannot touch the real application.
That design is only as strong as the isolation around the sandbox, and isolation for a client-side sandbox is a shared responsibility. The sandbox component provides one half; the page that embeds it has to provide the other half - the iframe attributes and framing controls that actually pen it in. When a component’s safety depends on the embedder remembering to add something, that is exactly the seam worth inspecting.
Recon
Working black-box, from the open internet with no account, we mapped the company’s hosts. The production assistant was internal, as expected. But a staging host of the same assistant was reachable on the public internet - a common and understandable thing, a preview environment that outlived its intended audience.
That staging host gave us the assistant’s client-side code to read. We looked at two things in particular: how the sandbox iframe received instructions from its parent page, and what framing controls the page itself set. The instruction channel was postMessage, the standard browser mechanism for a parent page and an iframe to talk across origins.
The hypothesis
A client-side sandbox is only as safe as the isolation the embedder applies. That principle drove the whole engagement.
Two specific things had to be true for the sandbox to be safe, and both were the embedder’s job. First, the sandbox’s postMessage handler had to validate that a message actually came from the real parent application - checking both the message’s origin and its source - before acting on it. A handler that runs code from any sender will run code from an attacker’s page too. Second, the page had to declare framing controls - a frame-ancestors directive or X-Frame-Options - so that only the company’s own origins could embed the sandbox in an iframe.
The hypothesis was that at least one of these was missing, because they are the easiest half of a sandbox to forget - the half that lives in the embedder’s config, not in the component’s own code. If the handler trusted any sender and the page let anyone frame it, an attacker page could load the sandbox and drive it.
What we tried
We read the message handler first. It acted on incoming postMessage events without validating event.origin and without validating event.source - it trusted any sender. Then we checked the response headers and the page’s policy: there was no frame-ancestors directive and no X-Frame-Options, so nothing stopped an arbitrary page from framing the sandbox.
That was the whole delivery mechanism. We built an attacker page that framed the staging sandbox and sent it a crafted message. Because the sandbox executed the message content through an on-the-fly compile step, our JavaScript ran on the real application origin - not in some neutered inner frame, but on the app’s own origin, where its cookies and access live.
The page did carry a restrictive Content-Security-Policy, which should have constrained what our injected code could do. We escaped it with a borrowed-realm technique: opening a same-origin popup and using its fetch to act in a context the policy did not constrain the same way. To prove the code execution was real and on the correct origin without touching any private data, we confirmed it live against a public health endpoint of the application - a request that returns non-sensitive status and demonstrates our code ran where we claimed.
Here is the boundary of what we proved, stated plainly. We demonstrated attacker JavaScript executing on the application’s origin. We did not demonstrate authenticated reads or writes against the application’s API. In a real browser session, code running on that origin would have the victim’s cookies attached automatically, so authenticated actions are a reasonable inference - but we had no account and used only a public endpoint, so that impact is inferred, not shown. We are stating it that way on purpose, because the difference between “we proved code execution on the origin” and “we drained the API” is exactly the kind of thing that should not be blurred.
What we found
The finding was an unauthenticated cross-origin code-execution flaw, built from two omissions that were each the embedder’s responsibility:
- The message handler trusted any sender. It validated neither
event.originnorevent.source, so it acted on messages from an attacker’s page as readily as from the real parent app. - The page set no framing controls. With no
frame-ancestorsand noX-Frame-Options, any page on the internet could embed the sandbox in an iframe.
attacker-page.html
<iframe src="https://<staging-sandbox>/..."></iframe>
iframe.postMessage(<attacker code>, "*")
-> sandbox handler runs it (no origin check, no source check)
-> on-the-fly compile executes attacker JS on the APP origin
-> restrictive CSP escaped via a same-origin popup's fetch (borrowed realm)
-> confirmed live against the app's public health endpoint
Put together: an attacker page could frame the sandbox, send it code, and have that code run on the real application’s origin, with a restrictive CSP escaped along the way. The safety of a code-execution sandbox depended entirely on the embedder adding the isolation attributes, and those attributes were omitted.
Why it happens
Nobody shipped this thinking the sandbox was exposed. The component was designed to be framed only inside a locked-down iframe, on a page that restricts who can embed it - and inside that intended envelope it is safe. The failure is a split-responsibility one: the part that makes the sandbox safe lives in the embedder’s configuration, not in the sandbox’s own code, and configuration is the easiest thing to leave for later.
Two normal decisions combined. A postMessage handler that accepts any sender is the path of least resistance during development, when the only page talking to the sandbox is your own. And framing controls are headers you have to remember to set; their absence is invisible until someone tries to frame you. Neither omission is visible in normal use, because in normal use the only embedder is the real app. The exposure appears only when an attacker becomes the embedder - which the missing framing controls happily allow, and the trusting handler happily serves.
For developers
If you embed a client-side sandbox, or ship one for others to embed, these controls close this class of flaw:
- Validate both origin and source in every postMessage handler. Check
event.originagainst an allowlist of your own origins, and checkevent.sourceis the exact frame you expect, before acting on any message. A handler that skips either check will act on an attacker’s messages. - Set frame-ancestors so only your origins can embed you. Use a Content-Security-Policy
frame-ancestorsdirective (andX-Frame-Optionsfor older clients) that names only your own origins. This stops an attacker page from framing the sandbox at all, removing the delivery mechanism. - Do not rely on the embedder to add the isolation your component’s safety depends on. If a sandbox is only safe when the embedding page adds specific attributes, ship those requirements enforced, documented, and failing-closed - not as an assumption. The safest component makes it hard to embed unsafely.
- Treat a client-side sandbox as attacker-reachable. Assume a hostile page will one day frame it and send it input. Design the handler and the framing policy so that assumption is survivable, rather than trusting that only your app will ever be the parent.
And one detection step: search your front-end for postMessage handlers and confirm each one validates origin and source, then check that every page hosting a sandbox sets frame-ancestors. A handler without those checks, on a page anyone can frame, is this bug.
The takeaway
A sandbox that trusts its parent is not a sandbox - it is a code-execution endpoint waiting for a new parent. Client-side isolation is a shared job: the component provides the container, but the embedder has to validate who is talking to it and control who is allowed to frame it, and when those controls live in config that gets forgotten, any page on the internet can become the trusted parent. Validate origin and source, lock down frame-ancestors, and never let a component’s safety depend on isolation the embedder might omit.
This teardown is one instance of a broader pattern - AI surfaces that execute untrusted input because the trust boundary was drawn in the wrong place. We wrote up the general case, and how to defend against it, in Prompt Injection and the Abusable AI Surface.
Frequently asked
What is a postMessage cross-origin code execution flaw?
How does frame-ancestors prevent this?
Why is client-side sandboxing not enough on its own?
This is one finding from a harness that runs continuously. See how Greywatch finds, proves, and fixes them.
How it works →

