Jorge Turbica / blog · Blog ← All articles ES · Leer en español
AI

One shared ChatGPT link was enough to steal your data: four small bugs, chained

An unchecked postMessage, an innerHTML sink and a CSP-allowed domain were enough to take over ChatGPT accounts. Lessons for rendering untrusted HTML.

Opening a shared ChatGPT link while signed in was enough for an attacker to read your private data or act on your behalf. None of the four bugs behind it was serious on its own; together they added up to full account takeover. Parameter Labs published the chain and Simon Willison summarised it on October 31, 2026. OpenAI has fixed it and paid a $5,000 bounty.

The interest goes well beyond ChatGPT: any product that renders HTML written by a model or a user (artifacts, previews, widgets, embedded dashboards) has the same attack surface.

The starting point: untrusted HTML in a sandboxed iframe

ChatGPT renders interactive HTML apps, what OpenAI first called "Canvas" and now calls "HTML previews", inside a sandboxed iframe. The security premise is simple: that code is untrusted and must not be able to reach OpenAI's APIs or private data. The whole attack is about breaking that boundary.

The chain, step by step

  1. An open channel into the parent page. ChatGPT uses the MathLive library for its graphs, and MathLive's virtual keyboard can be triggered from an iframe with postMessage(). Nothing stopped other sandboxed frames, including HTML preview frames, from sending that message.
  2. An innerHTML sink in the receiver. The code handling the message inserted its content with innerHTML, so anyone who could send the message could add HTML to the parent page.
  3. A fresh iframe, no sandbox. innerHTML does not execute <script> tags, but it does let you create a new iframe without the sandbox restrictions.
  4. A trusted domain that echoes whatever you ask. ChatGPT's CSP blocked inline scripts and only allowed scripts from a handful of domains, YouTube among them. YouTube's oEmbed endpoint could be made to reflect JavaScript back in a form the browser would execute.

With JavaScript running on ChatGPT's origin, pulling private data from the APIs and exfiltrating it through an image reference was trivial. The end result was a ChatGPT share link that, opened by another signed-in user, leaked their data.

What to take away

A sandbox protects the page from a frame, not frames from each other. The core bug is not in the preview iframe but in a message listener on the parent page that assumed only the intended frame would talk to it. Every addEventListener("message") is a public API for every frame on the page and should be treated as one: check event.source against the window you expect, not just event.origin, because sandboxed iframes without allow-same-origin have the origin "null" and share it with every other such frame.

Message data is text, not HTML. If a listener needs to render something, textContent or building nodes through the DOM API keeps a message from turning into markup. With innerHTML the question stops being "can it run scripts?" and becomes "what other elements can it create?", and an unsandboxed iframe is the answer.

A CSP host allowlist is only as safe as its most permissive endpoint. Allowing an entire domain for scripts includes every path on it that reflects attacker-controlled content. Per-script nonces or hashes, and hosting third-party scripts on your own origin, close that door better than a list of hosts.

Untrusted content belongs on another origin. Products that render generated HTML usually serve it from a separate domain with no cookies and no access to the main product's APIs, so even a sandbox escape does not land on the origin that matters. Here the escape ended on ChatGPT's own origin, with the user's session.

Here is the vulnerable pattern next to a defensive message listener:

javascript
// Vulnerable: any frame on the page can inject markup
window.addEventListener("message", (e) => {
  panel.innerHTML = e.data.html;
});

// Defensive: only the expected window, only text
window.addEventListener("message", (e) => {
  if (e.source !== widgetFrame.contentWindow) return;
  if (typeof e.data?.label !== "string") return;
  panel.textContent = e.data.label;
});

What does not carry over

The exploit details depend on ChatGPT-specific pieces: MathLive, its CSP and YouTube being on it. Copying a checklist from this case guarantees nothing for another app. What carries over is the method: list every postMessage listener on the page, every domain in the CSP and what each one can return, and assume an attacker will use all three at once.

Willison called the $5,000 payout "a bit cheap" for a bug this serious. For anyone building with agents and artifacts, the number matters less than the lesson: the attack surface of an AI product is not just the model.

Sources

Jorge Turbica

Software Engineer and SRE. I write about the real challenges I run into in production.

See portfolio ↗