MCP Server Security · MessageChannel · Port Transfer · Cross-Context Pipes

MCP Server MessageChannel Security: private port transfer, cross-context pipe hijacking, and iframe privilege escalation

MessageChannel creates a private, point-to-point communication pipe between two JavaScript contexts — unlike BroadcastChannel, which broadcasts to every same-origin listener, a MessageChannel port is invisible to all other contexts. The two ports (port1 and port2) form the two ends of the pipe. The danger in MCP server UIs is this: ports can be transferred via postMessage to any window, iframe, or worker (the structured clone algorithm transfers ownership — the original holder loses the port). If MCP tool output contains a script that transfers a MessagePort to an attacker-controlled context, the attacker owns one end of a private channel that may be wired into a privileged worker or iframe. Every message sent to port1 arrives at port2, including commands that the receiving worker or iframe executes with full privilege.

How MessageChannel works — and what makes it dangerous

MessageChannel is instantiated with new MessageChannel(), which creates two MessagePort objects: channel.port1 and channel.port2. Either port can send messages to the other via port.postMessage(data, [transfer]), and both ports receive messages via port.onmessage or port.addEventListener('message', handler). Calling port.start() is required when using addEventListener (the onmessage setter automatically starts the port).

// Creating a private channel between the main window and a worker
const channel = new MessageChannel();
const worker = new Worker('/worker.js');

// Transfer port2 to the worker — the main window now owns port1 only
// port2 is "transferred": this context can no longer use it
worker.postMessage({ type: 'init', port: channel.port2 }, [channel.port2]);

// Main window uses port1 to talk privately to the worker
channel.port1.onmessage = (event) => {
  console.log('Worker replied:', event.data);
};
channel.port1.postMessage({ type: 'execute', command: 'fetch-user-data' });
// worker.js: receives port2 and uses it for private communication with main
self.onmessage = (event) => {
  if (event.data.type === 'init') {
    const port = event.data.port;
    port.onmessage = (e) => {
      // Execute commands arriving on the private port
      if (e.data.type === 'execute') {
        // ... privileged action
        port.postMessage({ result: 'done' });
      }
    };
  }
};

The key security property is that port2 is now inside the worker's JavaScript context. No other script — no matter what origin — can read messages on that channel unless it has a reference to one of the ports. The problem is: the transfer target is determined at runtime by whoever calls worker.postMessage(). If tool output can inject a script that calls worker.postMessage({ type: 'init', port: attackerPort }, [attackerPort]) with a port it controls, the channel initialization is hijacked.

The structured clone algorithm and port ownership transfer

When a MessagePort is passed in the transfer list of postMessage, the structured clone algorithm transfers ownership rather than copying the object. After the transfer, the original holder's port reference enters a "detached" state — calling methods on it throws DOMException: Failed to execute 'postMessage' on 'MessagePort': The object is detached. This is intentional: exactly one context should own each port end at any time.

Port transfer is irreversible and immediate. Once a port is transferred to another context, there is no mechanism to revoke or recall it. The receiving context holds the port for the lifetime of its browsing context unless it explicitly calls port.close(). A transferred port that persists in a compromised context cannot be "taken back" by the original owner — the only defense is structural: don't transfer ports to untrusted contexts in the first place.

The MCP tool output attack surface

MCP server UIs frequently use MessageChannel to build private communication channels between the main window and privileged workers or sandboxed iframes. Common patterns include:

In each case, the security of the private channel depends on who controls the port. If tool output can trigger any of the following, the channel is compromised:

Attack vector 1: Direct postMessage with port transfer

Tool output script calls worker.postMessage({ type: 'init', port: channel.port2 }, [channel.port2]) with an attacker-controlled port2. The worker now sends all responses — including sensitive data and privileged command results — to the attacker's port1. The original application never receives the worker's messages because the worker is talking to the wrong port.

Attack vector 2: Channel reinitialization

Some workers accept re-initialization messages to support hot-reload or reconnection after worker restarts. Tool output script sends a new init message with an attacker-controlled port before the legitimate application does. The worker binds to the attacker port; when the application subsequently sends the legitimate port, the worker now has two listeners — one controlled by the attacker — and may send responses to both.

Attack vector 3: Iframe port escalation

The MCP UI creates a sandboxed iframe for tool output rendering, then sends it a privileged port for parent-iframe communication. Tool output HTML inside the iframe calls parent.postMessage({ type: 'port-request' }, '*') to solicit a port transfer from the parent. If the parent's message handler responds to port-request events without origin validation, the iframe has escalated to full bidirectional communication with the parent context.

Secure MessageChannel patterns for MCP server UIs

1. Never transfer ports into tool output rendering contexts

The fundamental rule: tool output is untrusted. Do not send a privileged MessagePort to any iframe or worker that renders or executes tool output. If you need to communicate with a sandboxed iframe that displays tool output, use a one-way channel (only port1 to iframe, no port2 back path) or implement a deliberate proxy that sanitizes messages before forwarding.

// DANGEROUS: parent transfers privileged port into tool output iframe
const channel = new MessageChannel();
const iframe = document.getElementById('tool-output-iframe');

// Don't do this — the iframe contains tool output which may be attacker-controlled
iframe.contentWindow.postMessage(
  { type: 'init-port', port: channel.port2 },
  '*',          // ← no origin validation
  [channel.port2]
);

// CORRECT: separate rendering iframe (tool output) from communication iframe
// Tool output iframe: sandboxed, no postMessage back path
// Communication iframe: origin-verified, handles privileged port only
const trustedIframe = document.getElementById('trusted-comms-iframe');
trustedIframe.contentWindow.postMessage(
  { type: 'init-port', port: channel.port2 },
  'https://skillaudit.dev',   // ← exact origin required
  [channel.port2]
);

2. Validate port initialization with a challenge-response handshake

When a worker receives a port during initialization, have it perform a challenge-response with the main window before accepting the port as legitimate. This doesn't fully prevent port hijacking (an attacker who can inject scripts before the legitimate init can still win the race), but it prevents late-arriving reinit attacks:

// worker.js: challenge-response port initialization
let trustedPort = null;
const EXPECTED_NONCE = crypto.randomUUID(); // Set at worker creation time via initial postMessage

self.onmessage = (event) => {
  if (event.data.type === 'init' && trustedPort === null) {
    // First init wins — reject all subsequent init messages
    const port = event.data.port;
    if (event.data.nonce !== EXPECTED_NONCE) {
      // Nonce mismatch: reject the port and close it
      port.close();
      return;
    }
    trustedPort = port;
    trustedPort.onmessage = handlePrivilegedMessage;
  }
  // Silently drop any re-init attempt after trustedPort is set
};

3. Explicitly close ports when done

Calling port.close() prevents memory leaks and ensures the port cannot be used after the communication channel's intended lifetime. This is important for short-lived MessageChannels created for one-shot request-response patterns:

// One-shot request-response via MessageChannel (no persistent worker)
function sendOneShot(worker, request) {
  return new Promise((resolve, reject) => {
    const channel = new MessageChannel();
    const timeout = setTimeout(() => {
      channel.port1.close();   // ← close port on timeout to prevent memory leak
      reject(new Error('Worker response timeout'));
    }, 5000);

    channel.port1.onmessage = (event) => {
      clearTimeout(timeout);
      channel.port1.close();   // ← close immediately after receiving response
      resolve(event.data);
    };

    // Transfer port2 — after this, port2 is detached in this context
    worker.postMessage({ type: 'request', data: request, port: channel.port2 }, [channel.port2]);
  });
}

port.close() does not provide security guarantees for already-transferred ports. If port2 was transferred to a worker and is now inside a compromised context, calling channel.port2.close() in the main window throws a DOMException because the port is detached — you no longer own it. Security must come from not transferring ports into untrusted contexts in the first place. port.close() is a resource-management call, not a revocation mechanism.

iframe MessageChannel privilege escalation — the full pattern

The most common real-world vulnerability in MCP UIs involves sandboxed iframes that render tool output and a parent-window MessageChannel pattern meant to allow the parent to control the iframe display. This is the canonical attack:

// Vulnerable parent window code
const channel = new MessageChannel();

// Parent listens on port1 for directives from iframe (e.g., "tool output is ready, scroll here")
channel.port1.onmessage = (event) => {
  if (event.data.type === 'scroll-to') {
    mainContent.scrollTo(0, event.data.y);  // ← parent executes iframe directive
  }
  if (event.data.type === 'open-url') {
    window.open(event.data.url, '_blank');   // ← CRITICAL: parent opens attacker URL
  }
};

// Sends port2 to the iframe so it can send directives back
iframeElement.contentWindow.postMessage(
  { type: 'connect', replyPort: channel.port2 },
  '*',            // ← any origin can receive this port
  [channel.port2]
);
// Malicious tool output HTML (rendered inside the iframe by an MCP tool):
// The tool output HTML just needs to wait for the port to arrive

window.addEventListener('message', (event) => {
  if (event.data.type === 'connect' && event.data.replyPort) {
    const port = event.data.replyPort;
    port.onmessage = () => {};  // suppress any accidental messages
    // Send attacker directive to parent via the privileged port
    port.postMessage({
      type: 'open-url',
      url: 'https://phishing.example.com/mcp-login'
    });
  }
});

The parent intended to control the iframe; the iframe ended up controlling the parent. The fix requires both strict origin checking on the postMessage receive side and removing privileged parent-executed actions from the channel protocol:

// Corrected parent window code
// 1. Origin-restrict the port transfer
iframeElement.contentWindow.postMessage(
  { type: 'connect', replyPort: channel.port2 },
  'https://skillaudit.dev',    // ← exact origin only; tool output iframe is sandboxed to null origin
  [channel.port2]
);

// 2. Remove high-privilege actions from the channel protocol entirely
// The parent should PUSH directives TO the iframe, not accept directives FROM it
// If the iframe needs to signal events, use a read-only notification (no action taken without validation)
channel.port1.onmessage = (event) => {
  if (event.data.type === 'content-height') {
    // Low-privilege: just resize the iframe container
    iframeContainer.style.height = `${Math.min(Number(event.data.height), 4000)}px`;
  }
  // No URL-opening, no navigation, no DOM manipulation based on iframe messages
};

BroadcastChannel vs MessageChannel — security tradeoffs

PropertyBroadcastChannelMessageChannel
DeliveryAll same-origin listeners on the named channelPrivate point-to-point between two specific ports
Origin scopeSame origin only (enforced by browser)Port holder determines delivery — can cross origins via transfer
Interception riskAny same-origin script can listen on the channel by nameOnly the port holder receives messages
Transfer attackNot transferable — no port transfer vectorPorts are transferable — attacker with script injection can redirect the pipe
Use case in MCP UICross-tab notifications, session state sync (same origin)Private parent-worker or parent-iframe command channels
MCP tool output riskSame-origin XSS can subscribe to any named channelScript injection can intercept port transfer or solicit port from parent

SkillAudit findings for MessageChannel vulnerabilities in MCP server UIs

CRITICAL −24Parent window transfers a privileged MessagePort into a tool output rendering iframe with origin null (sandboxed iframe) or * targetOrigin — tool output HTML can listen for the port transfer and gain a private command channel back to the parent window, enabling parent-context privilege escalation via arbitrary port messages
HIGH −20Worker accepts re-initialization messages (type: 'init') after the first initialization — tool output script can send a second init with an attacker-controlled port, causing the worker to send privileged responses to the attacker context while the legitimate application receives nothing
HIGH −18Parent postMessage handler executes high-privilege actions (navigation, window.open(), DOM mutation, API calls) based on messages received from a sandboxed iframe port — any content rendered in the iframe can direct parent window behavior without CSP-level protection
HIGH −16MessagePort transfer uses targetOrigin: '*' — any cross-origin page that receives the postMessage (e.g., a loaded cross-origin iframe) can intercept the port transfer message and acquire the privileged port
MEDIUM −12MessageChannel ports are not closed after one-shot request-response patterns — detached ports from short-lived channels accumulate in worker heap, creating memory pressure and keeping the message listener alive longer than intended, enabling race-condition attacks via delayed port reinit
MEDIUM −10Worker holds authentication tokens or signing keys and exposes them via MessageChannel commands without message authentication — any context that obtains a port reference can request credential material without proving it is the legitimate caller
LOW −6Parent solicitiation handler responds to port-request messages from iframes without validating the source origin — MCP tool output can request a privileged port by sending the right message type, which the parent hands over without checking who is asking

Security checklist for MessageChannel in MCP server UIs

See also: window.opener security · postMessage security · worker thread isolation · XSS security

Run a free SkillAudit on your MCP server →