MCP Server Security · Window Opener · Tab-Napping
MCP server window.opener security — tab-napping attacks, reverse tabnapping via window.opener.location redirect, noopener enforcement, and setWindowOpenHandler defense in MCP browser UIs
window.opener reference abuse is a browser attack where a newly opened tab or window retains a reference to the window that opened it via window.opener. If that reference is not severed with rel="noopener", the opened page can call window.opener.location = 'https://phishing.example.com' — silently redirecting the parent MCP window to an attacker-controlled page while the user is reading the newly opened tab. In MCP server UIs, tool output often contains links that open in new tabs. If those links are rendered without rel="noopener noreferrer", every external URL in tool output becomes a potential tab-napping vector.
The window.opener reference and why it's dangerous
When a page opens a new tab via window.open(url, '_blank') or an anchor with target="_blank" (without rel="noopener"), the browser establishes a bidirectional reference: the opener window can reach the new window via window.open()'s return value, and the new window can reach the opener via window.opener. This reference is cross-origin if the opened URL is on a different domain — and critically, cross-origin window.opener access is not fully blocked. The opened page can set window.opener.location to navigate the opener window to any URL, including a cross-origin one.
// Attacker-controlled page opened by the MCP UI (e.g., a URL from tool output)
// This script runs in the opened tab and redirects the opener (MCP UI) to a phishing page
if (window.opener) {
// Works cross-origin: setting .location on a cross-origin opener is allowed
window.opener.location = 'https://phishing.example.com/fake-mcp-login?session=stolen';
}
// More subtle variant: wait for the user to be distracted by the opened content
// then redirect the background tab they think is still their MCP session
setTimeout(() => {
if (window.opener && !window.opener.closed) {
window.opener.location.href = 'https://phishing.example.com/mcp-session-expired';
}
}, 3000);
The attack is effective because:
- The user opens a link from tool output (or the UI opens it automatically)
- They read the new tab, which appears legitimate
- In the background,
window.opener.locationredirects the MCP tab to a phishing page - When the user switches back to the MCP tab, they see a fake login prompt and enter their credentials
Reverse tabnapping from MCP tool output links
In MCP server UIs, the attack surface is particularly broad: tool output frequently contains URLs that the UI renders as clickable links. A search result tool might return 20 URLs. A web-scraping tool returns links from scraped pages. An email-reading tool returns URLs from email bodies. If the UI renders these as <a href="..." target="_blank"> without rel="noopener", every single link in tool output is a potential reverse tabnapping vector.
// DANGEROUS: rendering tool output URLs as links without noopener
function renderLinks(urls) {
return urls.map(url =>
`<a href="${escapeHtml(url)}" target="_blank">${escapeHtml(url)}</a>`
).join('\n');
// Missing rel="noopener noreferrer" — each opened URL has window.opener access to the MCP UI
}
// CORRECT: always include rel="noopener noreferrer" on external links
function renderLinks(urls) {
return urls.map(url => {
const safe = sanitizeUrl(url); // validate scheme is https: or http: only
if (!safe) return `<span style="color:var(--muted-2)">[blocked URL]</span>`;
return `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer">${escapeHtml(safe)}</a>`;
}).join('\n');
}
// sanitizeUrl: block javascript:, data:, file:, and other dangerous schemes
function sanitizeUrl(url) {
try {
const parsed = new URL(url);
if (!['https:', 'http:'].includes(parsed.protocol)) return null;
return url;
} catch {
return null;
}
}
rel="noopener" severs the opener reference; rel="noreferrer" additionally suppresses the Referer header. Use both together (rel="noopener noreferrer") on all external links. noopener sets window.opener to null in the opened window — the tab-napping redirect is impossible. noreferrer additionally prevents the opened site from seeing which URL referred the user — useful for preventing opened pages from logging that they were linked from your MCP UI. Modern browsers also set noopener implicitly for target="_blank" in some contexts, but do not rely on this browser behavior — always set it explicitly.
window.open() calls in MCP UI JavaScript
Beyond anchor tags, MCP UI JavaScript may call window.open() directly — to open tool output previews, OAuth redirect windows, or file download links. These calls must also pass the noopener feature string:
// DANGEROUS: window.open without noopener — opened window gets window.opener reference
window.open(url, '_blank');
// CORRECT: include 'noopener,noreferrer' in the features string
window.open(url, '_blank', 'noopener,noreferrer');
// In Electron MCP desktop clients: use setWindowOpenHandler to control all popups
// webContents.setWindowOpenHandler returns 'deny' for unexpected opens
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
const parsed = new URL(url);
// Only allow opening known-safe domains in new windows
const ALLOWED_HOSTS = new Set(['skillaudit.dev', 'docs.anthropic.com']);
if (ALLOWED_HOSTS.has(parsed.hostname)) {
// Open in system browser (not Electron window) to avoid opener reference
shell.openExternal(url);
}
// Always deny — prevents renderer from opening new Electron windows
return { action: 'deny' };
});
Content-Security-Policy frame-ancestors and cross-window attacks
While rel="noopener" handles the opener-to-opened direction, Content-Security-Policy: frame-ancestors 'self' protects against the MCP UI being embedded in an attacker's iframe (which is the clickjacking / UI redressing direction). Both controls are needed for a complete cross-window security posture:
| Attack | Direction | Defense |
|---|---|---|
| Tab-napping / reverse tabnapping | Opened tab → opener MCP window | rel="noopener noreferrer" on all outbound links |
| Clickjacking / UI redressing | Attacker iframe → embedded MCP UI | CSP frame-ancestors 'self' + X-Frame-Options: DENY |
| Cross-origin window.open targeting MCP window | Third-party page → MCP window (named target) | Avoid named window targets; use _blank consistently |
| Popup phishing via window.open from tool output | Tool output script → new popup window | CSP script-src blocks injected scripts; sandbox iframe blocks allow-popups |
SkillAudit findings for window.opener vulnerabilities in MCP server UIs
<a target="_blank"> without rel="noopener" — any URL returned by a tool that opens in a new tab has window.opener access to the MCP UI window; reverse tabnapping redirects the MCP session to a phishing page while the user reads the opened tabwindow.open() calls in MCP UI JavaScript do not pass 'noopener,noreferrer' in the features string — programmatically opened windows retain the window.opener reference and can redirect the parent MCP window cross-originsetWindowOpenHandler to deny or validate popup requests — renderer process JavaScript (including injected tool output scripts) can open arbitrary new Electron windows with Node.js access if nodeIntegration was enabled in any ancestor windowhref — javascript:, data:, and vbscript: URIs in tool output render as clickable links that execute script when clicked, even with rel="noopener"Content-Security-Policy: frame-ancestors 'self' — MCP UI can be embedded in an attacker's iframe for clickjacking attacks; no frame-ancestors directive means the browser permits framing from any originwindow.open(url, 'myWindow')) — a cross-origin page that knows the target name can hijack navigation in that named window by opening the same target name from their own page; use _blank to avoid name-collision targetingSee also: XSS security · Deep link injection · Electron security