MCP Server Security · MessageChannel · Port Transfer · Pipe Hijacking

MCP server MessageChannel security — private port transfer, cross-context pipe hijacking, iframe privilege escalation via MessagePort in MCP browser UIs

MessageChannel creates a private bidirectional pipe between two JavaScript contexts via port1 and port2. Unlike BroadcastChannel (all same-origin tabs hear every message), MessageChannel traffic is invisible to every context except the two port holders. The security risk in MCP server UIs: MessagePort objects can be transferred via postMessage — the structured clone algorithm transfers ownership rather than copying, so after the transfer only the receiving context holds the port. If tool output triggers a port transfer into an attacker-controlled context, that attacker holds a permanent private command channel into whatever worker, iframe, or window is on the other end. The transferred port cannot be revoked.

Port ownership transfer — why it's irreversible

Passing a MessagePort in the transfer list of postMessage moves ownership. After the transfer, the sender's port reference becomes detached — any method call throws DOMException: The object is detached. This "move" semantics (not "copy") means exactly one context holds each port end at any time. It also means there is no revocation: a port transferred into a compromised context cannot be closed or invalidated by the original owner. Defense must prevent the transfer from reaching an untrusted context at all.

// Structured clone with port transfer — irreversible ownership move
const channel = new MessageChannel();
const worker = new Worker('/audit-worker.js');

// After this call, channel.port2 is detached in this context
// The worker now owns port2 for the rest of its lifetime
worker.postMessage({ type: 'init', port: channel.port2 }, [channel.port2]);

// Any attempt to use channel.port2 here now throws:
try {
  channel.port2.postMessage('hello'); // DOMException: The object is detached
} catch (e) {
  console.error(e); // The worker owns port2 — you can no longer close it from here
}

Attack: port transfer into tool output rendering context

A common MCP UI pattern: the parent window creates a sandboxed iframe to render tool output HTML, then transfers a MessagePort into it so the iframe can signal back (e.g., "content is ready"). If the port transfer uses targetOrigin: '*' or the iframe has a null origin (sandboxed without allow-same-origin), any content loaded in that iframe — including attacker-controlled tool output HTML — receives the port.

// Vulnerable: parent transfers privileged port with wildcard targetOrigin
const channel = new MessageChannel();
iframe.contentWindow.postMessage(
  { type: 'connect', port: channel.port2 },
  '*',                     // ← any content in the iframe, regardless of origin, receives this
  [channel.port2]
);

// Attacker tool output HTML inside the iframe:
window.addEventListener('message', (e) => {
  if (e.data?.type === 'connect' && e.data.port) {
    e.data.port.onmessage = () => {};
    // port2 is now under attacker control — can send commands to whoever holds port1
    // In the parent, port1.onmessage executes: window.open(e.data.url, '_blank')
    e.data.port.postMessage({ type: 'open-url', url: 'https://phishing.example.com' });
  }
});

Never transfer a privileged MessagePort into a sandboxed iframe or any context that renders tool output. Sandboxed iframes have null origin, so you cannot use targetOrigin to restrict delivery — any content in the iframe receives the message regardless of targetOrigin value. The fix is structural: keep privileged ports entirely out of tool output rendering contexts.

Attack: port re-initialization via tool output script

Workers that accept new init messages to support hot-reload or reconnection are vulnerable to re-initialization attacks. Tool output script sends a new init before the legitimate app does, binding the worker to an attacker-controlled port:

// Vulnerable worker that accepts any init message
self.onmessage = (event) => {
  if (event.data.type === 'init') {
    // ← no guard: replaces existing trustedPort if already set
    trustedPort = event.data.port;
    trustedPort.onmessage = handleCommands;
  }
};

// Defense: first-init-wins with nonce validation
let trustedPort = null;

self.onmessage = (event) => {
  if (event.data.type === 'init') {
    if (trustedPort !== null) {
      event.data.port.close(); // close the attacker port
      return;                  // ignore re-init attempts
    }
    if (event.data.nonce !== EXPECTED_NONCE) {
      event.data.port.close();
      return;
    }
    trustedPort = event.data.port;
    trustedPort.onmessage = handleCommands;
  }
};

BroadcastChannel vs MessageChannel — security comparison

PropertyBroadcastChannelMessageChannel
DeliveryAll same-origin listeners on the named channelPrivate point-to-point between port holders only
Origin scopeSame origin only — browser-enforcedCan cross origins via port transfer
XSS riskSame-origin XSS subscribes by channel nameScript injection intercepts or solicits port transfer
RevocabilityClose the channel; all subscribers lose accessNo revocation after transfer — port lives in receiving context

SkillAudit findings for MessageChannel vulnerabilities

CRITICAL −24Parent transfers privileged MessagePort into tool output rendering iframe with targetOrigin: '*' or null-origin sandbox — tool output HTML receives the port and gains a private command channel into the parent or any privileged worker connected to the other port end
HIGH −20Worker accepts re-initialization init messages after first binding — tool output script can send a second init with an attacker port, causing the worker to redirect all privileged responses to the attacker context
HIGH −16targetOrigin: '*' on port transfer postMessage — any context loaded at that window or frame receives the port regardless of origin; cross-origin iframes in the page can intercept the port
MEDIUM −12Parent port1 handler executes privileged actions (navigation, credential requests, DOM mutation) on messages from a sandboxed iframe port — any content in the iframe can direct parent window behavior
MEDIUM −10Ports not closed after one-shot request-response patterns — detached ports accumulate in worker heap and keep message listeners alive, enabling late-arriving port reinit race conditions

See also: Full MessageChannel security deep-dive · postMessage security · Worker thread isolation

Run a free SkillAudit on your MCP server →