Browser Sandbox for Agent-Generated HTML (Sandboxed Iframe + Immutable CSP)¶
Run untrusted agent- or LLM-generated HTML in the browser by composing a sandboxed iframe, an immutable meta CSP, and a MessageChannel-scoped parent API.
When This Pattern Fits¶
The pattern fits when a coding agent emits a runnable HTML+JS artifact for execution inside an already-authenticated parent app, and all four hold:
- Blast radius bounded to the browser tab — no server-side state, file system, GPU, or background compute.
- Artifact delivered via
srcdoc(or any path where you control the first bytes), so the<meta>CSP is parsed before any guest script runs. - No need for
frame-ancestors, the CSPsandboxdirective, orreport-uri/report-to— none of those work in a<meta>policy, per the CSP spec (content-security-policy.com). - The parent never sets
sandbox="allow-scripts allow-same-origin"— that combination lets the embedded document remove its ownsandboxattribute (MDN).
If the parent stores significant private data and you can host artifacts on a separate origin, prefer the stronger Claude Artifacts shape (separate domain + HTTP-header CSP) discussed in When This Backfires.
The Three Layers¶
Each layer closes a distinct browser primitive; pulling any one re-opens a known exfiltration path. The shape below mirrors the datasette-apps plugin Simon Willison launched in June 2026 (Datasette Apps, launch post).
Layer 1: Iframe Sandbox Attribute¶
<iframe sandbox="allow-scripts allow-forms" srcdoc="..."></iframe>
allow-scripts permits JavaScript inside the frame; allow-forms permits in-frame form submission. Omitting allow-same-origin is load-bearing — without it, the embedded document is treated as a unique opaque origin that always fails the same-origin policy, so it cannot read DOM, cookies, or localStorage from the parent (MDN: <iframe> sandbox). What this does not stop: outbound fetch(), <img>, <script src>, or stylesheet loads to arbitrary external hosts — any of which can carry data in the URL (Simon Willison: Datasette Apps).
Layer 2: Immutable Meta CSP¶
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:;">
default-src 'none' collapses every fetch surface to "blocked." The two 'unsafe-inline' exceptions re-enable inline scripts and styles the artifact ships with; img-src data: blob: permits in-page synthesised images without admitting any network origin. Once parsed, the policy is immutable: cross-browser testing on Chromium and Firefox confirms that JavaScript inside the iframe cannot remove, modify, or escape the <meta> tag, including via document replacement or data: URI navigation (simonw/research: test-csp-iframe-escape).
Layer 3: MessageChannel-Scoped Parent API¶
With layers 1–2 the iframe can do nothing useful. To open it back up safely, hand the child a single MessagePort at instantiation and expose only an allow-listed surface — for datasette-apps, that is read-only SQL plus admin-pre-registered stored-query writes (Simon Willison: Datasette Apps). The parent verifies each request against its allow-list before executing it.
Prefer MessageChannel over raw window.postMessage for two reasons. First, the port reference is private — it never leaks to attacker-controlled code that ends up in the frame. Second, the channel auto-closes if the iframe navigates away, eliminating "child reloads to attacker code, channel still open" exploits (MDN: MessageChannel; Simon Willison: Datasette Apps). If you must use raw postMessage, validate event.source and event.origin on every message (MDN: postMessage security).
Why It Works¶
The three layers are independent browser primitives that intersect on the threat. The sandbox attribute opaque-origins the frame, severing DOM and storage reach into the parent. The meta CSP is parsed at document-construction time, before any guest script runs; per the HTML spec and Simon's empirical cross-browser tests, the resulting policy is immutable for the lifetime of the document (simonw/research: test-csp-iframe-escape). default-src 'none' then forces every fetch through the CSP allow-list, which is empty for external origins. The MessageChannel port is the only output surface left, and the parent only honours requests on a fixed allow-list. Pull any one layer and a known exfil channel opens — DOM read-back, network egress, or arbitrary parent-side commands — so the defense-in-depth holds only when all three are present and configured exactly as above.
When This Backfires¶
- Sensitive parents with separate-domain hosting available. Claude Artifacts hosts each artifact on
claudeusercontent.comand delivers CSP via HTTP header (Claude Artifacts overview). That shape engages cross-site browser process isolation, unlocks theframe-ancestors/report-to/ CSPsandboxdirectives that<meta>cannot carry (content-security-policy.com), and removes any parse-order concern. For sensitive auth-bearing parents the separate-origin variant is the right default; same-domainsrcdocis the fallback when separate hosting is impractical. - Users can add arbitrary origins to the CSP allow-list. Any allow-listed origin is a data egress channel. Simon's Claude Fable security review caught a real attack: a less-privileged user with
create-apppermission allow-listed their own server, then social-engineered an admin into running the app — the app inherited the admin's query authority and exfiltrated to the allow-listed origin (Simon Willison: Datasette Apps). Gate CSP allow-list mutation to trusted operators only. - The message bridge accepts arbitrary parent commands. A bridge that proxies untyped commands re-creates the original threat at a different layer. Datasette Apps explicitly limits the bridge to read-only SQL plus pre-registered stored-query writes, with the parent verifying both the database and the query name before executing (Simon Willison: Datasette Apps).
- You need server-side state, file system, GPU, or background compute. The browser sandbox bounds blast-radius but does not provide those capabilities. For those workloads run a server-side sandbox instead — see In-Process WebAssembly Sandboxes for Agent-Generated Code for in-process execution, or the broader Sandbox Runtime Comparison for containers, microVMs, and OS-level isolators.
- Any allow-listed origin re-hosts user-controlled content. CSP allow-lists are bypass-prone when an allow-listed host serves JSONP, hosts user uploads, or supports
data:script sources (Content Security Policy bypass survey, 2026). Treat every allow-listed origin as data egress.
Example¶
A minimal artifact host that runs an LLM-generated HTML document under all three layers:
<iframe id="artifact"
sandbox="allow-scripts allow-forms"
srcdoc='<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
script-src 'unsafe-inline';
style-src 'unsafe-inline';
img-src data: blob:;">
</head>
<body>
<script>
window.addEventListener("message", (e) => {
if (e.data?.type === "init" && e.ports[0]) {
const port = e.ports[0];
port.postMessage({type: "query", sql: "select count(*) from items"});
port.onmessage = (m) => document.body.textContent = JSON.stringify(m.data);
}
});
</script>
</body>
</html>'></iframe>
<script>
const iframe = document.getElementById("artifact");
iframe.addEventListener("load", () => {
const channel = new MessageChannel();
channel.port1.onmessage = async (m) => {
// Allow-list check: this parent only accepts {type: "query", sql: string}
// and only against pre-registered safe queries.
if (m.data?.type !== "query" || !isAllowedQuery(m.data.sql)) return;
const result = await runReadOnlySql(m.data.sql);
channel.port1.postMessage(result);
};
iframe.contentWindow.postMessage({type: "init"}, "*", [channel.port2]);
});
</script>
Key Takeaways¶
- The pattern is the composition of three browser primitives — sandbox attribute, immutable meta CSP, MessageChannel — each closing a distinct exfiltration path
- Omit
allow-same-originfrom the sandbox token list; together withallow-scriptsit lets the embedded document remove its own sandbox - Meta CSP cannot carry
frame-ancestors, the CSPsandboxdirective, or violation reporting — use an HTTP-header CSP on a separate origin when you need them - Prefer
MessageChannelover rawpostMessage; the port reference is private and the channel dies on iframe navigation - Gate CSP origin allow-list mutation to trusted operators — any user-allow-listed origin is a data egress channel
- Use this when blast-radius can be bounded to the browser tab; reach for server-side sandboxes when the workload needs filesystem, network, or sustained compute
Related¶
- In-Process WebAssembly Sandboxes for Agent-Generated Code
- Dual-Boundary Sandboxing: Filesystem and Network Isolation
- Sandboxed Coding Environments: Containers vs MicroVMs vs OS-Level Isolators
- Agent-Authored Messages as a Deferred Exfiltration Channel
- Guarding Against URL-Based Data Exfiltration in Agentic Workflows
- Defense-in-Depth Agent Safety