MCP Server Security · Sandboxed Iframes · postMessage API
MCP server sandboxed iframe postMessage security — origin validation, message schema validation, allowlist of expected message types, structured clone vs eval deserialization in MCP tool output iframes
Sandboxed iframes are the recommended way to render MCP tool output in a browser UI — they isolate tool output from the parent document's origin, preventing prompt-injection-triggered scripts from accessing the session, DOM, or APIs of the main MCP application. But iframes communicate with their parent via postMessage, and postMessage introduces its own attack surface: any page on any origin can post a message to any window it has a reference to. Without rigorous origin validation, message type allowlisting, and schema validation, the postMessage channel from the sandbox to the parent becomes an injection vector that circumvents the isolation the sandbox was designed to provide.
The postMessage threat model for MCP tool output iframes
When an MCP UI renders tool output in a sandboxed iframe (a blob URL iframe with sandbox=""), the parent and iframe need to communicate for legitimate purposes: the iframe may need to report its rendered height (for auto-sizing), signal that the user clicked a link (so the parent can handle navigation), or pass sanitized output back for parent-side rendering. All of this communication happens via postMessage.
The threat is two-directional:
- External origin → parent: Any page with a reference to the parent window (e.g., via
window.openerfrom a tab-napping attack) can post messages to the parent. If the parent'smessageevent handler doesn't checkevent.origin, external pages impersonate the trusted iframe. - Compromised iframe → parent: If the tool output sandbox is escape-prone (e.g.,
sandbox="allow-scripts allow-same-origin"), injected scripts within the iframe can post maliciously structured messages to the parent, triggering unintended parent-side behavior.
The critical mistake: no origin check in message handler
// DANGEROUS: parent listens for postMessage without origin validation
// Any page that has a reference to this window can trigger these handlers
window.addEventListener('message', (event) => {
// No event.origin check — any origin's message is processed
const { type, data } = event.data;
if (type === 'resize') {
toolOutputFrame.style.height = data.height + 'px';
}
if (type === 'navigate') {
window.location.href = data.url; // CRITICAL: any origin can redirect the MCP UI
}
if (type === 'updateToolResult') {
// Overwrite tool result in application state — data not validated
store.dispatch({ type: 'SET_RESULT', payload: data });
}
});
// CORRECT: validate origin before processing any message
const TRUSTED_IFRAME_ORIGINS = new Set([
'null', // blob: URL iframes report origin as 'null'
'https://skillaudit.dev', // if any same-origin iframes also postMessage
]);
window.addEventListener('message', (event) => {
// Check origin first — reject messages from unexpected origins
if (!TRUSTED_IFRAME_ORIGINS.has(event.origin)) {
console.warn('Rejected postMessage from unexpected origin:', event.origin);
return;
}
// Validate the source is actually our expected iframe (not a spoofed origin)
if (event.source !== toolOutputIframe.contentWindow) {
console.warn('Rejected postMessage from unexpected source window');
return;
}
processIframeMessage(event.data);
});
Blob URL iframes report event.origin === 'null'. When you create an iframe with src = URL.createObjectURL(blob), the iframe's origin is the opaque null origin — event.origin for messages from this iframe will be the string "null", not null. Check event.origin === 'null' (string comparison) for blob URL iframes. But also check event.source — any sandbox iframe loads as null origin, so origin-only checking doesn't distinguish your trusted iframe from an attacker's sandboxed iframe that also has a reference to your parent window.
Message schema validation: allowlist expected types
import { z } from 'zod';
// Define every message type the iframe is expected to send — reject anything else
const IframeMessageSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('resize'),
height: z.number().int().min(0).max(10000),
}),
z.object({
type: z.literal('contentLoaded'),
charCount: z.number().int().min(0),
}),
z.object({
type: z.literal('linkClick'),
// Only allow https: links to be reported; block javascript: and data: URLs
url: z.string().url().refine(
url => url.startsWith('https://') || url.startsWith('http://'),
{ message: 'Only http(s) links allowed' }
),
}),
]);
function processIframeMessage(rawData) {
let message;
try {
message = IframeMessageSchema.parse(rawData);
} catch (err) {
// Unknown message type or invalid schema — log and ignore
console.warn('Invalid iframe message schema:', err.errors);
return;
}
switch (message.type) {
case 'resize':
toolOutputFrame.style.height = `${message.height}px`;
break;
case 'contentLoaded':
// Update analytics
break;
case 'linkClick':
// Open in new tab with noopener — never navigate the parent
window.open(message.url, '_blank', 'noopener,noreferrer');
break;
}
}
Structured clone vs eval-based deserialization
The postMessage API uses the structured clone algorithm to serialize and deserialize message data. This is safe: structured clone does not execute code during deserialization, handles circular references correctly, and preserves most JavaScript types. In contrast, any deserialization that passes through eval(), new Function(), or unsafe JSON.parse() with a reviver that calls eval is dangerous:
// SAFE: postMessage uses structured clone — no eval, no code execution
iframe.contentWindow.postMessage({ type: 'render', content: toolOutput }, '*');
// Receiving side also uses structured clone via event.data — safe
// DANGEROUS: some older patterns serialized/deserialized with eval
// (you may encounter this in legacy code or third-party iframe bridges)
const serialized = JSON.stringify(data);
iframe.contentWindow.postMessage(serialized, '*');
// DANGEROUS receiving side:
window.addEventListener('message', (event) => {
const data = eval('(' + event.data + ')'); // eval-based deserialization — NEVER do this
// OR:
const data = JSON.parse(event.data, (key, value) => {
if (typeof value === 'string' && value.startsWith('__fn__')) {
return eval(value.replace('__fn__', '')); // function revival via eval — NEVER
}
return value;
});
});
// SAFE: use event.data directly (structured clone) or JSON.parse without reviver
window.addEventListener('message', (event) => {
// event.data is already deserialized via structured clone — access directly
const data = event.data; // SAFE
// If string was sent, parse without eval:
const data2 = JSON.parse(event.data); // SAFE — JSON.parse without reviver
});
Prototype pollution via postMessage
JSON.parse with a reviver that assigns to arbitrary keys can cause prototype pollution if the message contains __proto__ or constructor keys. The structured clone algorithm itself does not have this vulnerability — it ignores non-enumerable properties and does not traverse the prototype chain. But if you manually merge received message data into existing objects, pollution is possible:
// DANGEROUS: merging postMessage data into an object without validation
window.addEventListener('message', (event) => {
if (event.origin !== 'null') return;
// Attacker sends: { "__proto__": { "isAdmin": true } }
Object.assign(appState, event.data); // pollutes Object.prototype.isAdmin
});
// CORRECT: validate schema first (Zod .strict() rejects __proto__ keys),
// then only extract known properties
window.addEventListener('message', (event) => {
if (event.origin !== 'null' || event.source !== toolFrame.contentWindow) return;
const parsed = IframeMessageSchema.parse(event.data); // schema rejects unexpected keys
// Use only parsed properties — never spread rawData into state
handleMessage(parsed.type, parsed); // type-safe access only
});
Sandbox attribute configuration for tool output iframes
| Sandbox token | Effect if included | Recommendation for tool output |
|---|---|---|
allow-scripts | Enables JavaScript execution inside the iframe | Omit — tool output should not execute scripts. If auto-sizing via ResizeObserver is needed, inject the resize script via the blob URL HTML template (trusted code), not from tool output. |
allow-same-origin | Iframe inherits the parent's origin; can access parent DOM, cookies, and postMessage without origin restrictions | Omit — this defeats the sandboxing. Never combine with allow-scripts. |
allow-popups | Iframe can open new browser windows | Omit — tool output should not spawn popups. Handle link navigation via postMessage to the parent. |
allow-forms | Iframe can submit HTML forms | Omit — tool output should not submit forms. Inject a form submission blocker in the blob template if the content might include form tags. |
allow-top-navigation | Iframe can navigate the top-level browsing context | Omit — tool output must never redirect the parent window. This token would allow injected content to navigate the MCP UI. |
SkillAudit findings for postMessage security in MCP sandboxed iframes
message event handler does not check event.origin — any page with a reference to the MCP parent window can post messages that the handler processes as trusted iframe messages; attacker from a tab-napping position triggers navigate or updateState handlers to redirect the MCP UI or corrupt application statesandbox="allow-scripts allow-same-origin" — this combination is equivalent to no sandbox: scripts in the iframe have full same-origin access to the parent's DOM, cookies, and localStorage; tool output scripts bypass all isolationtype string from postMessage, including undocumented or injected types; injected messages with unexpected types trigger unhandled code paths or corrupt state via untested execution flowsevent.source check alongside event.origin — checking only origin='null' is insufficient when multiple blob-URL iframes or sandboxed third-party iframes exist on the page; any 'null'-origin iframe can post messages that are processed as if from the trusted tool output iframeObject.assign() or spread operator without Zod schema validation — __proto__ and constructor keys in the message payload pollute Object.prototype and affect all object operations in the applicationallow-top-navigation — tool output injected into the sandboxed iframe can navigate the parent browsing context (top-level window) to an attacker-controlled URL, bypassing the sandbox's origin isolationwindow.location.href = data.url) rather than opening in a new tab — injected linkClick message with a javascript: or data:text/html URL executes script in the parent context via the navigation handlerSee also: XSS security · Browser history security · CSP deep dive