Skip to content

iframe Sandbox Lessons From the ChatGPT Account Takeover

iframe Sandbox Lessons From the ChatGPT Account Takeover

I have a confession about iframes. For years I treated the sandbox attribute like a seatbelt: I clicked it in, felt safe, and never once asked what the car was doing. Then I read Simon Willison’s link post about how researchers took over ChatGPT accounts and realised the seatbelt was fine. The problem was a door nobody had locked, three feet away.

Short version for the impatient: the sandbox held. The attack worked because the parent page had a message handler that wrote whatever it received into the DOM with innerHTML, and it never asked who was talking. If your app embeds untrusted HTML and also listens for postMessage, that handler is the thing to audit this week, not the iframe attribute.

I’ll walk through the chain as Simon summarised it, then show the handler pattern I now use in my own code. I’m working from his write-up and not from the original researchers’ report, so treat my account of the details as a summary and go read the sources yourself.

What the attack chain actually did

ChatGPT lets you preview interactive HTML apps in a sandboxed iframe. That’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.

According to Simon’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 postMessage(). The receiving code used innerHTML, so anything able to send a message could push HTML into the parent. Nothing stopped a sandboxed preview frame from sending one.

Inserting HTML is not the same as running script. innerHTML won’t execute a <script> element you hand it. But it will happily create an <iframe>, 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.

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’s word, trivial. Share a link, wait for a signed-in person to open it, collect their data.

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.

Why the sandbox attribute wasn’t the problem

The sandbox attribute did its job. A sandboxed frame without allow-same-origin gets an opaque origin, so it can’t read the parent’s cookies or storage, and it can’t touch the parent’s DOM directly. What it can still do, if you let it run scripts, is call window.parent.postMessage(). That channel is deliberate. It’s the supported way for a restricted frame to talk to the page around it.

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.

Here’s the pattern that worries me:

// Before: trusts every message from every frame
window.addEventListener('message', (event) => {
  const { type, html } = event.data;
  if (type === 'show-keyboard') {
    document.getElementById('kb').innerHTML = html;
  }
});

Nothing here checks event.origin. Nothing checks event.source. The payload shape is whatever the sender says it is. And the innerHTML write means the sender controls markup, which is the exact step the researchers needed.

A handler I would actually ship

The MDN documentation for postMessage is blunt about this: always verify the sender’s identity using the origin and possibly source properties, and be wary of what you do with the data. Here’s my current version, rewritten after reading the ChatGPT write-up:

// After: known sender, known shape, no markup from outside
const KEYBOARD_ORIGIN = 'https://app.example.com';
const keyboardFrame = document.getElementById('keyboard-frame');

window.addEventListener('message', (event) => {
  if (event.origin !== KEYBOARD_ORIGIN) return;
  if (event.source !== keyboardFrame.contentWindow) return;

  const data = event.data;
  if (!data || data.type !== 'show-keyboard') return;
  if (typeof data.layout !== 'string') return;

  const layouts = { numeric: 'numeric', text: 'text' };
  const layout = layouts[data.layout];
  if (!layout) return;

  document.getElementById('kb').dataset.layout = layout;
});

Four things changed. The handler compares the origin against one exact string, not a prefix and not endsWith. It also compares event.source 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 data- attribute that my own CSS reads.

That last change is the one that matters most. If a handler needs to render something the sender supplied, use textContent, or build elements with createElement. If it genuinely has to accept markup, run it through a sanitizer such as DOMPurify and then think hard about whether you really needed markup.

One more trap with sandboxed frames: their origin is the string "null". If you ever write if (event.origin === 'null') to accept messages from a sandboxed preview, you’ve just accepted messages from every sandboxed frame on the internet. It’s an easy line to write when a preview pane refuses to load, so search your code for it. The fix is checking event.source against a frame reference you created, because origin can’t tell opaque frames apart.

The combination that disables your own sandbox

While I’m confessing, here’s the other classic. The MDN iframe reference warns that combining allow-scripts and allow-same-origin 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’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.

If you host user-supplied HTML, serve it from a different registrable domain, not just a different subdomain, and keep allow-same-origin 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.

My rule of thumb now:

<iframe
  src="https://preview.example-usercontent.net/abc123"
  sandbox="allow-scripts"
  referrerpolicy="no-referrer"
  loading="lazy"
></iframe>

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.

The allowlist entry nobody reviews

The detail in the story that bothers me most is the YouTube entry in the CSP. I’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.

If you want a stricter setup, nonce-based CSP with strict-dynamic avoids the host allowlist problem altogether. I wrote about the nonce route, and the one inline style attribute I couldn’t lock down, in my React security post. It isn’t free. Nonces mean server-rendered headers and some careful plumbing. But a host allowlist has a failure mode you can’t see from the policy text, and a nonce doesn’t.

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’s private data. I agree with him, with the caveat that I don’t know the programme’s rules or how OpenAI scored it. The takeaway for me is practical: a payout doesn’t tell you how much the bug would have cost if someone less friendly found it first.

Things to do this week

Open your codebase and search for addEventListener('message' and onmessage. 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 innerHTML, outerHTML, insertAdjacentHTML or document.write?

Then search for sandbox= and look for any frame carrying both allow-scripts and allow-same-origin. If the content in that frame isn’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 what I work on.

Do the grep before lunch tomorrow. Fix the worst innerHTML first, because that single change would have broken the chain described above.