A ChatGPT Share Link Could Hijack Accounts: 4 postMessage Bugs to Check
On October 28, researchers at Parameter published a write-up of a bug that let a public ChatGPT share link run attacker-controlled JavaScript inside the signed-in session of whoever opened it. OpenAI fixed it on October
On October 28, researchers at Parameter published a write-up of a bug that let a public ChatGPT share link run attacker-controlled JavaScript inside the signed-in session of whoever opened it. OpenAI fixed it on October 26, two days before the post went public, and rated it P1. This is not a "ChatGPT is broken" story. It is already fixed, and the interesting part is not ChatGPT at all.
The interesting part is that the sandbox worked. The HTML Preview ran in a restricted frame on a different origin and could not read the main page. What failed was a third-party listener in the main page that was willing to take orders from that frame. If your app embeds anything, loads a widget library, or calls postMessage anywhere, you have the same shape of problem waiting to happen.
Everything below about the incident comes from Parameter's write-up, "Do the Math". I'm not adding anything about ChatGPT that they didn't publish.
The chain, in five links
-
A graph loads MathLive. A normal interactive graph in a ChatGPT conversation loaded a math library called MathLive. Loading it installed a
messagelistener in the main chatgpt.com page for messages about its on-screen keyboard. -
An untrusted sender. A later HTML Preview in the same conversation ran in the isolated frame. It could not touch
parent.document, butpostMessageis built to cross exactly that boundary. - A listener that didn't check who was talking. MathLive's controller used an origin policy of "none", so it accepted messages from any origin, and it did not require the state-changing message to come from a window it had approved. The message replaced the virtual keyboard layout, and a layout could contain HTML.
-
HTML becomes a document with chatgpt.com's identity. When the keyboard opened, the layout was assigned to
innerHTMLin the real page. Inline script tags don't run that way, but an<iframe srcdoc>does create a fresh document. MathLive inserted that frame without asandboxattribute, so the new document inherited thehttps://chatgpt.comorigin. A helper namedcreateHTMLlooked like a sanitizer; per Parameter, it just marked the string as trusted and passed it through. -
CSP allowed one more door. The Content Security Policy blocked inline JavaScript but allowed scripts from YouTube. YouTube's oEmbed endpoint accepted a JSONP-style
callbackparameter and echoed it back inside the error response, so a callback ending in;voidturned an error message into runnable code from an allowed host.
From there the code ran as chatgpt.com. Parameter showed it could list the victim's recent conversations, read a private one that had never been shared, and send a small piece of it to a server they controlled. In their words, opening the shared link was the only required action in the most automated version they reproduced.
What to take from it: four mistakes your app may also have
The fix list at the end of Parameter's post is short. Here it is as four things you can grep for.
1. A message listener with no origin or source check
This is the root of the whole chain. A listener that changes state must verify both who sent the message and which window sent it:
const ALLOWED_ORIGIN = "https://widgets.example.com";
let trustedWindow = null; // set when WE create or open the frame
window.addEventListener("message", (event) => {
if (event.origin !== ALLOWED_ORIGIN) return; // right site?
if (event.source !== trustedWindow) return; // right window?
if (typeof event.data !== "object" || event.data === null) return;
handle(event.data);
});
Checking event.origin alone is not enough when several frames can share an origin, and checking nothing, or comparing against "*", is how you end up here. Also look at the sending side: postMessage(data, "*") hands your data to whoever is in that frame right now. Always pass the exact target origin.
A quick way to find your listeners:
grep -rnE "addEventListener\(['\"]message['\"]" src/ --include=*.{js,ts,tsx,jsx}
grep -rnE "postMessage\(.*['\"]\*['\"]\)" src/ --include=*.{js,ts,tsx,jsx}
Then do the same in node_modules for the libraries you ship to the browser, because the vulnerable listener in this story was not written by the product team.
2. Accepting HTML in a cross-window message
Parameter's lesson was blunt: cross-window settings should not contain raw HTML. A message that says "change this setting" should carry values from a small allowed set, not markup that will be written into the page.
const LAYOUTS = new Set(["qwerty", "numeric", "compact"]);
function applyLayout(msg) {
if (!LAYOUTS.has(msg.layout)) return; // names, not markup
keyboard.setLayout(msg.layout);
}
If you must render user-influenced markup, sanitize it with a real sanitizer at the sink. An identity function that is named createHTML or wrapped as a Trusted Types policy does not make a string safe. Trusted Types limits who can assign to a sink, but an identity policy passes whatever it is given.
3. Creating iframes without sandbox
The unsandboxed srcdoc frame is what turned HTML injection into same-origin script execution, because a frame you create from your own page inherits your origin. Any iframe whose content can be influenced by someone else should be sandboxed, ideally with an empty allowlist and permissions added one at a time:
<iframe sandbox="allow-scripts" srcdoc="..."></iframe>
Be careful with allow-same-origin combined with allow-scripts: together they let the framed document remove its own sandbox. Don't add both for untrusted content.
4. A CSP that allows a host you don't control
CSP did its job against inline script, and the allowlisted YouTube endpoint still got around it. Parameter says blocking only that endpoint would not have fixed the real problem, since the untrusted Preview could still put HTML into the page. Even so, it's worth checking your own policy:
- Open your
script-srcandscript-src-elemand list every host. - For each one, ask whether it serves anything that reflects a query parameter as JavaScript (JSONP endpoints,
callback=). - Prefer nonces or hashes with
'strict-dynamic'over long host allowlists.
CSP should be the backup control. If attacker-controlled markup reaches your main origin, you've already lost the primary one.
A small Django-side habit that helps
If your Django app serves any user-generated HTML back to other users, such as shared documents, reports, or "preview" pages, serve it from a separate origin and keep it there, which is the part ChatGPT got right. Add a strict CSP to those responses and never put session cookies on that domain:
# middleware.py
class PreviewCSPMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if request.path.startswith("/preview/"):
response["Content-Security-Policy"] = (
"default-src 'none'; script-src 'self'; style-src 'self'; "
"frame-ancestors 'self'; sandbox allow-scripts"
)
response["X-Content-Type-Options"] = "nosniff"
return response
That doesn't replace the checks above. It limits the damage if one of them is missed.
The 20-minute audit I'd run on any web app after reading this
- Search for every
messagelistener, including inside bundled libraries, and write down what each one can change. - Search for
postMessage(calls with"*"as the target origin. - Search for
innerHTML,outerHTML,insertAdjacentHTML, andsrcdocassignments, and trace where each string comes from. - Check every
<iframe>you create dynamically for asandboxattribute. - Read your CSP and look for hosts that can reflect a parameter as script.
I haven't run this on a codebase specifically because of the ChatGPT incident, so I'm not going to claim results from it. The checklist is simply what I'd do, because the failure in this chain wasn't exotic. It was a missing if statement in a place nobody on the product team was looking.
Where this leaves the "isolation" story
Parameter's closing point is the one I'd keep: every feature looked reasonable on its own. Preview was isolated, graphs could load a math keyboard, frames could exchange messages, CSP allowed a major media provider, and public conversations could be shared. The vulnerability lived in the seams between them.
That's a useful way to review your own app. Don't ask "is this widget safe?" Ask "what can the least trusted thing on this page say to the most trusted thing, and what does the receiver do with it?"
Does your app check event.origin on every message listener, including the ones your libraries add? Or is "we sandbox it" doing more work in your threat model than it should? Tell me in the comments.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.