Security reference · Browser · postMessage
MCP server postMessage security
window.postMessage is the standard mechanism for cross-origin communication between browser windows, tabs, and iframes. Browser-based MCP client UIs use it to communicate between the agent chat interface, embedded tool output frames, and companion extension sidebars. A postMessage listener without explicit origin validation accepts messages from any window on the web — including attacker-controlled pages opened by the user. This reference covers the four postMessage attack classes for MCP clients: no-origin-check tool invocation, window.opener access after popup opens, message flooding, and prototype pollution via unvalidated JSON payloads.
Attack 1: No origin validation → arbitrary tool invocation
The most common postMessage vulnerability: the listener checks event.data.type but not event.origin. Any window — including an attacker's page that the user opened in a separate tab — can send a message with the expected type and trigger a tool call.
// VULNERABLE — no origin check
window.addEventListener('message', event => {
if (event.data.type === 'mcp-tool-call') {
// Any window on the internet can send this message
callMcpTool(event.data.tool, event.data.args);
}
});
// Attack: attacker page sends:
// opener.postMessage({ type: 'mcp-tool-call', tool: 'deleteFile', args: { path: '~/.ssh/id_rsa' } }, '*')
// or via iframe:
// document.getElementById('victimIframe').contentWindow.postMessage(...)
// SECURE — explicit origin allowlist
const TRUSTED_POSTMESSAGE_ORIGINS = new Set([
'https://your-mcp-client.example', // same origin (own iframes, popups)
'https://companion-extension.example', // trusted companion origin, if any
// NEVER add '*' or untrusted origins
]);
window.addEventListener('message', event => {
// Reject messages from untrusted origins immediately
if (!TRUSTED_POSTMESSAGE_ORIGINS.has(event.origin)) return;
// Additional: check that source is expected (own iframe, not a random tab)
if (event.source !== expectedIframeRef.contentWindow) return;
// Now safe to process the message
handleMcpMessage(event.data);
});
Checking if (event.origin) is not validation. event.origin is always truthy (it's a string). The check if (event.origin) { doSomething() } is equivalent to no check — it processes messages from all origins. Origin validation requires an explicit allowlist comparison: TRUSTED_ORIGINS.has(event.origin).
Attack 2: window.opener — popup windows can access the MCP client page
When the MCP client page opens a popup (OAuth redirect, authentication flow, external tool UI), the popup receives a reference to the opener via window.opener. If the popup is a different origin, browser same-origin policy prevents reading the opener's DOM — but the popup can still call window.opener.postMessage() to send messages, and can navigate the opener to a different URL.
// VULNERABLE: MCP client opens OAuth popup
function openOauthPopup(url) {
window.open(url, 'oauth', 'width=500,height=600');
// Popup at url now has: window.opener === MCP client page
// If url is ever compromised or redirected to attacker control,
// the popup can: window.opener.postMessage({type:'mcp-tool-call',...}, '*')
// AND: window.opener.location = 'https://attacker.example/phish'
}
// SECURE: use rel="noopener" equivalent for window.open
function openOauthPopup(url) {
// noopener prevents window.opener from being set in the popup
const popup = window.open(url, 'oauth', 'width=500,height=600,noopener,noreferrer');
// Now popup.opener === null — popup cannot reference the MCP client page
return popup;
}
// For <a> tags that open popups or new tabs:
// <a href="..." target="_blank" rel="noopener noreferrer">...</a>
// noopener: prevents window.opener in the new tab
// noreferrer: also prevents the Referer header (privacy)
// Checking if your MCP client page is itself accessed via window.opener
// (e.g., if your client is opened as a popup from another page)
if (window.opener !== null) {
// This page was opened as a popup — be cautious about postMessage from opener
// An untrusted opener can send messages to this page
window.addEventListener('message', event => {
// Validate origin even more strictly when accessed as a popup
if (event.source === window.opener) {
// Only trust the opener if it's your own known origin
if (event.origin !== 'https://your-known-launcher.example') return;
}
});
}
Attack 3: Message flooding — DoS via high-volume postMessage
A malicious page that has sent any postMessage (regardless of whether it passes origin checks) can flood the MCP client with thousands of messages per second. Each message triggers an event listener call, potentially overwhelming the JavaScript event loop and freezing the UI.
// postMessage rate limiter — reject excess messages from same source
const messageCounts = new WeakMap(); // source window → { count, resetAt }
const MESSAGE_RATE_LIMIT = 50; // max messages per 1s window
const MESSAGE_RATE_WINDOW_MS = 1000;
function checkRateLimit(source) {
const now = Date.now();
let state = messageCounts.get(source);
if (!state || now > state.resetAt) {
state = { count: 0, resetAt: now + MESSAGE_RATE_WINDOW_MS };
messageCounts.set(source, state);
}
state.count++;
return state.count <= MESSAGE_RATE_LIMIT;
}
window.addEventListener('message', event => {
// Rate limit BEFORE origin check to prevent DoS
if (!checkRateLimit(event.source)) {
console.warn('postMessage rate limit exceeded from:', event.origin);
return;
}
// Then origin check
if (!TRUSTED_POSTMESSAGE_ORIGINS.has(event.origin)) return;
handleMcpMessage(event.data);
});
Attack 4: Prototype pollution via unvalidated postMessage JSON
If a postMessage handler deserializes the message payload with JSON.parse and then merges it into an object using a deep merge function, an attacker can send a payload with __proto__ keys that pollute Object.prototype. This can affect all objects in the MCP client page, enabling privilege escalation or logic bypass:
// VULNERABLE: deep merge with postMessage payload
window.addEventListener('message', event => {
if (event.origin !== trustedOrigin) return; // origin check passes
const payload = JSON.parse(event.data); // or event.data if already parsed
// lodash merge follows __proto__
const config = merge({}, defaultConfig, payload);
// If payload = { "__proto__": { "isAdmin": true } }
// Now all objects have isAdmin === true
// if (user.isAdmin) gives true for every user object
});
// SECURE: schema validation before any merge
import { z } from 'zod';
const McpMessageSchema = z.object({
type: z.enum(['tool-call', 'cancel', 'ping']),
requestId: z.string().uuid(),
tool: z.string().regex(/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/).optional(),
args: z.record(z.unknown()).optional(),
});
window.addEventListener('message', event => {
if (!TRUSTED_POSTMESSAGE_ORIGINS.has(event.origin)) return;
// Parse and validate against schema before any processing
let message;
try {
const raw = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
message = McpMessageSchema.parse(raw); // throws if invalid
} catch {
console.warn('Invalid postMessage schema from:', event.origin);
return;
}
// Use only the validated fields
if (message.type === 'tool-call' && message.tool) {
callMcpTool(message.tool, message.args ?? {});
}
});
Sending postMessage securely: always specify the target origin
// When sending postMessage from the MCP client to a child iframe or popup, // always specify the exact target origin — never use '*' // VULNERABLE: broadcasts to any origin iframeEl.contentWindow.postMessage(sensitiveData, '*'); // If the iframe navigates to an attacker domain, they receive sensitiveData // SECURE: target-origin is explicit const IFRAME_ORIGIN = 'https://sandbox.your-mcp-client.example'; iframeEl.contentWindow.postMessage(data, IFRAME_ORIGIN); // Delivery fails (silently) if the iframe has navigated to a different origin
SkillAudit findings for postMessage security
event.data.type) but not event.origin — origin is checked only for truthy value, not against an allowlist.
noopener — popup receives window.opener reference and can navigate or message the client page.
__proto__ key in message can affect all objects.
'*' — if iframe navigates to an attacker domain, sensitive tool call data is delivered to the attacker.
Run a full security audit of your MCP server at skillaudit.dev — postMessage, prototype pollution, opener attacks, and 40+ additional checks.