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:
- An audit worker that runs analysis on tool output and communicates results back via a private channel
- A sandboxed iframe rendering tool output HTML, with the main window holding
port1to send directives (scrollTo, highlight, dismiss) - A crypto worker holding signing keys, accessible only via port messages from the main window
- A session worker that manages authentication tokens, responding to requests over a MessageChannel port
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
| Property | BroadcastChannel | MessageChannel |
|---|---|---|
| Delivery | All same-origin listeners on the named channel | Private point-to-point between two specific ports |
| Origin scope | Same origin only (enforced by browser) | Port holder determines delivery — can cross origins via transfer |
| Interception risk | Any same-origin script can listen on the channel by name | Only the port holder receives messages |
| Transfer attack | Not transferable — no port transfer vector | Ports are transferable — attacker with script injection can redirect the pipe |
| Use case in MCP UI | Cross-tab notifications, session state sync (same origin) | Private parent-worker or parent-iframe command channels |
| MCP tool output risk | Same-origin XSS can subscribe to any named channel | Script injection can intercept port transfer or solicit port from parent |
SkillAudit findings for MessageChannel vulnerabilities in MCP server UIs
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 messagestype: '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 nothingwindow.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 protectiontargetOrigin: '*' — 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 portport-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 askingSecurity checklist for MessageChannel in MCP server UIs
- Never transfer a privileged
MessagePortinto a sandboxed iframe or any context that renders or executes tool output - Use exact
targetOrigin(never'*') when transferring ports viapostMessage - Initialize workers with a one-time nonce; reject all re-initialization messages after the first accepted init
- Remove high-privilege actions (navigation, URL-opening, credential access) from MessageChannel protocol — the channel protocol should only carry display directives (resize, scroll position)
- Call
port.close()immediately after one-shot request-response patterns resolve or timeout - Do not respond to
port-requestorconnectmessages from iframes without strict origin validation - Prefer push-only channels (parent sends directives to iframe) over bidirectional channels (iframe can also command parent)
See also: window.opener security · postMessage security · worker thread isolation · XSS security