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

CRITICAL −22 postMessage handler triggers MCP tool calls without any origin validation — any window on the web can invoke arbitrary tools in the authenticated client session.
HIGH −18 postMessage handler validates message structure (event.data.type) but not event.origin — origin is checked only for truthy value, not against an allowlist.
HIGH −16 MCP client opens external OAuth/auth popups without noopener — popup receives window.opener reference and can navigate or message the client page.
HIGH −14 postMessage payload deep-merged into application state without schema validation — prototype pollution via __proto__ key in message can affect all objects.
MEDIUM −10 No postMessage rate limiting — flooding from a cross-origin window can freeze the MCP client UI event loop.
MEDIUM −8 postMessage sent to child iframes using target-origin '*' — 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.