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
| Property | BroadcastChannel | MessageChannel |
|---|---|---|
| Delivery | All same-origin listeners on the named channel | Private point-to-point between port holders only |
| Origin scope | Same origin only — browser-enforced | Can cross origins via port transfer |
| XSS risk | Same-origin XSS subscribes by channel name | Script injection intercepts or solicits port transfer |
| Revocability | Close the channel; all subscribers lose access | No revocation after transfer — port lives in receiving context |
SkillAudit findings for MessageChannel vulnerabilities
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 endinit 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 contexttargetOrigin: '*' 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 portSee also: Full MessageChannel security deep-dive · postMessage security · Worker thread isolation