Security reference · Clickjacking · UI security

MCP server clickjacking advanced security

Clickjacking attacks overlay an invisible or transparent frame over a legitimate MCP admin UI, tricking an authenticated user into performing unintended actions — approving a tool invocation, revoking a session, or confirming a dangerous operation. While X-Frame-Options is the standard defense, it has four known bypass classes that matter for MCP servers with admin interfaces: subframe navigation attacks (a child frame navigates to your admin URL, bypassing parent-level framing checks), portal element abuse (the HTML <portal> element allows embedding pages without X-Frame-Options enforcement in browsers that support it), framebusting bypass in sandboxed iframes (the sandbox attribute prevents your framebusting JavaScript from navigating away), and double-framing bypass for SAMEORIGIN (an outer frame at your own origin embeds a frame from a cross-origin attacker, which then loads your UI in a nested iframe that the SAMEORIGIN policy incorrectly allows). CSP frame-ancestors is the only mechanism that addresses all four.

Why X-Frame-Options alone is insufficient

X-Frame-Options has two values: DENY (no framing at all) and SAMEORIGIN (only frames from the same origin). It was designed for a simpler era of HTML and has two fundamental gaps:

No cascading check for SAMEORIGIN. The SAMEORIGIN check only examines the immediate parent frame — not the entire frame ancestry chain. A page at your-app.com can legitimately embed an iframe (from its own origin), and that iframe can load an <iframe src="your-app.com/admin"> as a grandchild. The grandchild sees the immediate parent as same-origin and allows framing, even though the outer attacker page initiated the chain.

No portal element coverage. The <portal> element (shipping in Chrome 85+ as an Origin Trial, enabled in some versions) allows seamless page-in-page embedding with smoother transitions than <iframe>. It doesn't go through the X-Frame-Options check in all implementations.

Attack 1: Double-framing SAMEORIGIN bypass

<!-- attacker.com/attack.html -->
<!-- Outer iframe at a same-origin page (requires a CORS-accessible iframe at victim origin,
     or uses a same-origin page the attacker found with open redirect / XSS) -->
<iframe src="https://app.example.com/blank-page-that-accepts-nested-frame">
  <!-- This page is at the same origin as /admin -->
  <!-- Inside it, loads: -->
  <iframe src="https://app.example.com/admin/delete-user?id=123"></iframe>
</iframe>

The inner /admin/delete-user frame's immediate parent is app.example.com/blank-page — same origin. The SAMEORIGIN check passes. The outer attacker frame is invisible; it overlays a fake "Confirm" button over the Delete User button in the inner frame.

CSP frame-ancestors catches this. The frame-ancestors directive checks the full ancestry chain — every frame in the hierarchy must match an allowed origin, not just the immediate parent.

Attack 2: Framebusting bypass via sandbox attribute

Framebusting is a JavaScript technique that detects when the page is loaded inside an iframe and navigates the top window to break out:

// Common framebusting pattern in legacy MCP admin UIs
if (window.top !== window.self) {
  window.top.location = window.self.location;
}

This is bypassed by the sandbox attribute, which restricts what the framed page's JavaScript can do. Specifically, without allow-top-navigation, the framebusting script cannot navigate window.top:

<!-- attacker.com — framebusting bypass -->
<iframe
  src="https://app.example.com/admin/approve-tool"
  sandbox="allow-scripts allow-forms"
  >
  <!-- allow-scripts lets the page's JS run, but without allow-top-navigation
       the framebusting script's window.top.location assignment silently fails -->
</iframe>

The admin page loads, its framebusting script runs, but window.top.location = ... is blocked by the sandbox. The attacker's frame stays in place.

Framebusting JavaScript is not a reliable clickjacking defense. It can be bypassed via sandbox, disabled by JavaScript errors, or defeated by timing attacks. Use CSP frame-ancestors instead — it's enforced by the browser before JavaScript executes.

Attack 3: Portal element embedding

The HTML <portal> element provides seamless navigation with the appearance of a smooth page transition. In browsers that implement it, <portal> does not enforce X-Frame-Options in the same way as <iframe>:

<!-- attacker.com — portal element bypass (Chrome with portal origin trial) -->
<portal src="https://app.example.com/admin/settings"
        style="width:100%;height:100%;position:fixed;top:0;left:0;opacity:0.01">
</portal>
<!-- Fake UI overlaid on top -->
<div style="position:fixed;top:200px;left:200px">
  <button onclick="document.querySelector('portal').activate()">
    Click to claim your reward
  </button>
</div>

When the user clicks the fake button, portal.activate() navigates to the admin settings page — performing whatever action the attacker positioned under the fake button.

CSP frame-ancestors 'none' covers portal elements in browsers that respect it for portals. As an additional defense, serve the admin UI with Permissions-Policy: portal=() to explicitly disable portal embedding:

# Caddy — complete anti-framing headers for MCP admin endpoints
@admin {
  path /admin/*
}
header @admin {
  Content-Security-Policy "frame-ancestors 'none'"
  X-Frame-Options "DENY"
  Permissions-Policy "portal=()"
}

Attack 4: Subframe navigation to bypass top-level X-Frame-Options

An attacker page can load an innocent-looking page as an <iframe> (one that doesn't set X-Frame-Options), then navigate that iframe's location to the target admin URL using JavaScript. Since X-Frame-Options is checked at load time and not on subsequent navigations, the iframe's initial benign load passes the check — and then the attacker's script navigates it to the protected resource:

<!-- attacker.com -->
<iframe id="f" src="https://example.com/no-xfo-page"></iframe>
<script>
  setTimeout(() => {
    // After the innocent page loads, navigate the frame to the admin UI
    // X-Frame-Options is NOT rechecked on same-origin navigation within a frame
    document.getElementById("f").contentWindow.location =
      "https://app.example.com/admin/delete-user?id=123";
  }, 100);
</script>

This only works if the initial innocent page and the target admin page are same-origin (so the outer frame has cross-origin access to navigate the inner frame's location), which limits its practical scope. But combined with an XSS on a same-origin page with no X-Frame-Options, it becomes a viable attack chain.

The complete defense: CSP frame-ancestors

Content-Security-Policy: frame-ancestors is the correct and complete clickjacking defense. Unlike X-Frame-Options, it:

// Express — set CSP frame-ancestors for admin routes
app.use("/admin", (req, res, next) => {
  // 'none' = no framing allowed at all (equivalent to X-Frame-Options: DENY)
  res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
  // Keep X-Frame-Options as a fallback for very old browsers
  res.setHeader("X-Frame-Options", "DENY");
  next();
});

// For the main app that needs to allow embedding in your own app:
app.use("/embed", (req, res, next) => {
  // Only allow framing from specific trusted origins
  res.setHeader(
    "Content-Security-Policy",
    "frame-ancestors 'self' https://trusted-partner.com"
  );
  next();
});

Comparison: X-Frame-Options vs. CSP frame-ancestors

Attack vector X-Frame-Options DENY CSP frame-ancestors 'none'
Basic iframe embedding Blocks Blocks
Double-framing SAMEORIGIN bypass Bypassed (only checks immediate parent) Blocks (checks full ancestry)
Sandboxed iframe framebusting bypass Only if DENY is set (no JS involved) Blocks (enforced before JS executes)
Portal element embedding Not enforced by all browsers Blocks in CSP-compliant implementations
Subframe navigation to target Limited coverage Blocks on navigation
Browser support Universal (including IE) All modern browsers (CSP Level 2+)

Recommendation: serve both X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none'. The CSP provides the stronger guarantee; X-Frame-Options serves as a fallback for ancient browsers that don't support CSP.

SkillAudit findings

Finding → Grade Impact
Critical Admin UI has no framing protection (no X-Frame-Options, no CSP frame-ancestors) — clickjacking trivially possible. −20 points.
High X-Frame-Options SAMEORIGIN without CSP frame-ancestors — double-framing bypass exploitable on any page with a frameable sub-resource. −12 points.
High Framebusting JavaScript as only clickjacking defense — bypassed by sandbox attribute. −10 points.
High No Permissions-Policy: portal=() header — portal element embedding not blocked. −8 points.
Medium X-Frame-Options set correctly but CSP frame-ancestors absent — old browsers lack complete protection. −5 points.
Medium framing-protection headers missing on POST endpoints (only set on GET) — form-submission clickjacking unprotected. −5 points.

Run a clickjacking security audit. SkillAudit checks for missing CSP frame-ancestors, X-Frame-Options coverage on all admin endpoints, portal element protection, and framebusting script patterns on MCP server admin interfaces. Audit your server →