{"id":818,"date":"2026-11-03T13:01:43","date_gmt":"2026-11-03T13:01:43","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/iframe-sandbox-postmessage-chatgpt-html-preview-the-handler-i-would-audit\/"},"modified":"2026-11-03T13:01:43","modified_gmt":"2026-11-03T13:01:43","slug":"iframe-sandbox-postmessage-chatgpt-html-preview-the-handler-i-would-audit","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/iframe-sandbox-postmessage-chatgpt-html-preview-the-handler-i-would-audit\/","title":{"rendered":"iframe Sandbox Lessons From the ChatGPT Account Takeover"},"content":{"rendered":"<p>I have a confession about iframes. For years I treated the <code>sandbox<\/code> attribute like a seatbelt: I clicked it in, felt safe, and never once asked what the car was doing. Then I read Simon Willison&rsquo;s <a href=\"https:\/\/simonwillison.net\/2026\/Oct\/31\/how-we-took-over-chatgpt-accounts\/\" rel=\"nofollow noopener\" target=\"_blank\">link post about how researchers took over ChatGPT accounts<\/a> and realised the seatbelt was fine. The problem was a door nobody had locked, three feet away.<\/p>\n<p>Short version for the impatient: the sandbox held. The attack worked because the parent page had a <code>message<\/code> handler that wrote whatever it received into the DOM with <code>innerHTML<\/code>, and it never asked who was talking. If your app embeds untrusted HTML and also listens for <code>postMessage<\/code>, that handler is the thing to audit this week, not the iframe attribute.<\/p>\n<p>I&rsquo;ll walk through the chain as Simon summarised it, then show the handler pattern I now use in my own code. I&rsquo;m working from his write-up and not from the original researchers&rsquo; report, so treat my account of the details as a summary and go read the sources yourself.<\/p>\n<h2 id=\"what-the-attack-chain-actually-did\">What the attack chain actually did<\/h2>\n<p>ChatGPT lets you preview interactive HTML apps in a sandboxed iframe. That&rsquo;s the same idea as the artifacts pattern you may have seen elsewhere. The point of the sandbox is that the HTML inside is untrusted. It might be written by a stranger, shared through a link, and opened by you while signed in.<\/p>\n<p>According to Simon&rsquo;s summary, the researchers at Parameter Labs found a chain of small problems. The parent page used the MathLive library for graph support, and MathLive ships its own virtual keyboard. That keyboard can be triggered in the parent page through <code>postMessage()<\/code>. The receiving code used <code>innerHTML<\/code>, so anything able to send a message could push HTML into the parent. Nothing stopped a sandboxed preview frame from sending one.<\/p>\n<p>Inserting HTML is not the same as running script. <code>innerHTML<\/code> won&rsquo;t execute a <code>&lt;script&gt;<\/code> element you hand it. But it will happily create an <code>&lt;iframe&gt;<\/code>, and a new iframe created by the parent has no sandbox unless the parent adds one. So the attacker got an unsandboxed frame inside the page.<\/p>\n<p>Then came the content security policy. It blocked inline scripts and only allowed scripts from a short list of domains. One of those domains was YouTube, and the YouTube oEmbed endpoint could be coaxed into reflecting JavaScript back in a form that ran. With script running on the ChatGPT origin, fetching private data from the APIs and sending it out through an image request was, in Simon&rsquo;s word, trivial. Share a link, wait for a signed-in person to open it, collect their data.<\/p>\n<p>I find the shape of this more useful than the specific bug. Four separate decisions, each defensible in a code review on its own, added up to account takeover. A message handler that trusts its sender. A DOM write that accepts markup. A frame that inherits no restrictions. An allowlist entry for a big, friendly domain. Remove any single one and the chain breaks.<\/p>\n<h2 id=\"why-the-sandbox-attribute-wasnt-the-problem\">Why the sandbox attribute wasn&rsquo;t the problem<\/h2>\n<p>The <code>sandbox<\/code> attribute did its job. A sandboxed frame without <code>allow-same-origin<\/code> gets an opaque origin, so it can&rsquo;t read the parent&rsquo;s cookies or storage, and it can&rsquo;t touch the parent&rsquo;s DOM directly. What it can still do, if you let it run scripts, is call <code>window.parent.postMessage()<\/code>. That channel is deliberate. It&rsquo;s the supported way for a restricted frame to talk to the page around it.<\/p>\n<p>So the real question is what your page does when a message arrives. Think of that handler as a public API endpoint, because functionally it is one. Any frame that can reach your window can call it, including frames you never meant to host.<\/p>\n<p>Here&rsquo;s the pattern that worries me:<\/p>\n<pre><code class=\"language-js\">\/\/ Before: trusts every message from every frame\nwindow.addEventListener('message', (event) =&gt; {\n  const { type, html } = event.data;\n  if (type === 'show-keyboard') {\n    document.getElementById('kb').innerHTML = html;\n  }\n});\n<\/code><\/pre>\n<p>Nothing here checks <code>event.origin<\/code>. Nothing checks <code>event.source<\/code>. The payload shape is whatever the sender says it is. And the <code>innerHTML<\/code> write means the sender controls markup, which is the exact step the researchers needed.<\/p>\n<h2 id=\"a-handler-i-would-actually-ship\">A handler I would actually ship<\/h2>\n<p>The <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Window\/postMessage\" rel=\"nofollow noopener\" target=\"_blank\">MDN documentation for <code>postMessage<\/code><\/a> is blunt about this: always verify the sender&rsquo;s identity using the <code>origin<\/code> and possibly <code>source<\/code> properties, and be wary of what you do with the data. Here&rsquo;s my current version, rewritten after reading the ChatGPT write-up:<\/p>\n<pre><code class=\"language-js\">\/\/ After: known sender, known shape, no markup from outside\nconst KEYBOARD_ORIGIN = 'https:\/\/app.example.com';\nconst keyboardFrame = document.getElementById('keyboard-frame');\n\nwindow.addEventListener('message', (event) =&gt; {\n  if (event.origin !== KEYBOARD_ORIGIN) return;\n  if (event.source !== keyboardFrame.contentWindow) return;\n\n  const data = event.data;\n  if (!data || data.type !== 'show-keyboard') return;\n  if (typeof data.layout !== 'string') return;\n\n  const layouts = { numeric: 'numeric', text: 'text' };\n  const layout = layouts[data.layout];\n  if (!layout) return;\n\n  document.getElementById('kb').dataset.layout = layout;\n});\n<\/code><\/pre>\n<p>Four things changed. The handler compares the origin against one exact string, not a prefix and not <code>endsWith<\/code>. It also compares <code>event.source<\/code> to the specific frame I expect, because two different frames can share an origin. The payload is validated against a closed set of values. And nothing from the message ever becomes markup. It becomes a <code>data-<\/code> attribute that my own CSS reads.<\/p>\n<p>That last change is the one that matters most. If a handler needs to render something the sender supplied, use <code>textContent<\/code>, or build elements with <code>createElement<\/code>. If it genuinely has to accept markup, run it through a sanitizer such as <a href=\"https:\/\/github.com\/cure53\/DOMPurify\" rel=\"nofollow noopener\" target=\"_blank\">DOMPurify<\/a> and then think hard about whether you really needed markup.<\/p>\n<p>One more trap with sandboxed frames: their origin is the string <code>\"null\"<\/code>. If you ever write <code>if (event.origin === 'null')<\/code> to accept messages from a sandboxed preview, you&rsquo;ve just accepted messages from every sandboxed frame on the internet. It&rsquo;s an easy line to write when a preview pane refuses to load, so search your code for it. The fix is checking <code>event.source<\/code> against a frame reference you created, because origin can&rsquo;t tell opaque frames apart.<\/p>\n<h2 id=\"the-combination-that-disables-your-own-sandbox\">The combination that disables your own sandbox<\/h2>\n<p>While I&rsquo;m confessing, here&rsquo;s the other classic. The <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/HTML\/Element\/iframe\" rel=\"nofollow noopener\" target=\"_blank\">MDN iframe reference<\/a> warns that combining <code>allow-scripts<\/code> and <code>allow-same-origin<\/code> on a frame whose content is served from your own origin lets that content reach into the parent and remove the sandbox attribute entirely. It&rsquo;s a one-line escape. People add both flags because the embedded app breaks without same-origin access to local storage, the app starts working, and the ticket closes.<\/p>\n<p>If you host user-supplied HTML, serve it from a different registrable domain, not just a different subdomain, and keep <code>allow-same-origin<\/code> off. Separate origin for untrusted content is the boring, old, reliable answer. Google, GitHub and others have used the same trick for years for exactly this reason.<\/p>\n<p>My rule of thumb now:<\/p>\n<pre><code class=\"language-html\">&lt;iframe\n  src=&quot;https:\/\/preview.example-usercontent.net\/abc123&quot;\n  sandbox=&quot;allow-scripts&quot;\n  referrerpolicy=&quot;no-referrer&quot;\n  loading=&quot;lazy&quot;\n&gt;&lt;\/iframe&gt;\n<\/code><\/pre>\n<p>Scripts allowed, nothing else, different site entirely. If the preview needs forms or popups I add those flags one at a time and write down why in the commit message.<\/p>\n<h2 id=\"the-allowlist-entry-nobody-reviews\">The allowlist entry nobody reviews<\/h2>\n<p>The detail in the story that bothers me most is the YouTube entry in the CSP. I&rsquo;d have waved it through too. Embedding videos is a normal product need, and YouTube is not a hostile domain. But a script-src allowlist trusts everything that domain will serve, including endpoints that reflect input. Large shared domains tend to have many such endpoints.<\/p>\n<p>If you want a stricter setup, nonce-based CSP with <code>strict-dynamic<\/code> avoids the host allowlist problem altogether. I wrote about the nonce route, and the one inline style attribute I couldn&rsquo;t lock down, in <a href=\"https:\/\/abrarqasim.com\/blog\/react-security-best-practices-csp-nonce-the-style-attribute-i-couldnt-lock-down\/\" rel=\"noopener\">my React security post<\/a>. It isn&rsquo;t free. Nonces mean server-rendered headers and some careful plumbing. But a host allowlist has a failure mode you can&rsquo;t see from the policy text, and a nonce doesn&rsquo;t.<\/p>\n<p>I also want to say something about the bounty. Simon points out that the reported $5,000 payout seemed low for a bug that could expose a signed-in user&rsquo;s private data. I agree with him, with the caveat that I don&rsquo;t know the programme&rsquo;s rules or how OpenAI scored it. The takeaway for me is practical: a payout doesn&rsquo;t tell you how much the bug would have cost if someone less friendly found it first.<\/p>\n<h2 id=\"things-to-do-this-week\">Things to do this week<\/h2>\n<p>Open your codebase and search for <code>addEventListener('message'<\/code> and <code>onmessage<\/code>. Every hit is a trust boundary. For each one, answer three questions: who can send to this window, what happens if they lie about the payload shape, and does any value end up in <code>innerHTML<\/code>, <code>outerHTML<\/code>, <code>insertAdjacentHTML<\/code> or <code>document.write<\/code>?<\/p>\n<p>Then search for <code>sandbox=<\/code> and look for any frame carrying both <code>allow-scripts<\/code> and <code>allow-same-origin<\/code>. If the content in that frame isn&rsquo;t entirely yours, you have the same shape of problem the researchers exploited. I do this kind of review for clients, and you can see <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">what I work on<\/a>.<\/p>\n<p>Do the grep before lunch tomorrow. Fix the worst <code>innerHTML<\/code> first, because that single change would have broken the chain described above.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>The iframe sandbox held in the ChatGPT account takeover. The unchecked postMessage handler and innerHTML write did the damage. Here is the audit I would run.<\/p>\n","protected":false},"author":2,"featured_media":817,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"The iframe sandbox held in the ChatGPT account takeover. The unchecked postMessage handler and innerHTML write did the damage. Here is the audit I would run.","rank_math_focus_keyword":"iframe sandbox","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[35],"tags":[891,889,44,890,154],"class_list":["post-818","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-web-development","tag-csp","tag-iframe","tag-javascript","tag-postmessage","tag-security"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/818","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=818"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/818\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/817"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=818"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=818"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=818"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}