MCP Server Security · Browser History API
MCP server browser history security — history API leakage, history.pushState manipulation via injected content, history sniffing, and navigation timing attacks in MCP browser UIs
Browser history attacks in MCP server UIs exploit the window.history API to corrupt navigation state, and CSS/timing side channels to infer which URLs a user has visited. An MCP UI that renders tool output as HTML without sandboxing allows prompt injection payloads to call history.pushState() — poisoning the back-button stack, injecting false navigation history, or overwriting the current URL state to hide evidence of the attack. Separately, classic history sniffing attacks use CSS :visited rendering differences to probe whether the user has visited specific URLs, exposing browsing history across origins.
History.pushState manipulation via injected tool output
The History API lets single-page applications update the URL bar and navigation stack without a full page load. history.pushState(state, title, url) adds an entry to the history stack; history.replaceState() overwrites the current entry. Both execute synchronously with no user confirmation and no same-origin restriction on the path component — any same-origin script can push arbitrary entries onto the stack.
In an MCP UI that renders tool output as HTML (or executes JavaScript from tool output), a prompt injection payload can abuse this:
// Prompt injection payload embedded in malicious tool output
// Goal: corrupt the user's navigation history to confuse state, hide attack trace,
// or cause the back button to navigate to an attacker-chosen URL state
<script>
// Replace current history entry with a different URL — hides the MCP page from history
history.replaceState({}, '', '/innocent-looking-page');
// Push multiple fake entries — clicking "back" navigates through attacker-controlled states
for (let i = 0; i < 20; i++) {
history.pushState({ step: i }, '', `/step-${i}`);
}
// Overwrite the state object — corrupts the SPA's router state on back-navigation
history.replaceState({ injectedState: '__proto__' }, '', window.location.pathname);
</script>
The url parameter of pushState is restricted to same-origin paths — you cannot push https://evil.example.com via this API. But within the same origin, any path can be pushed, which is sufficient for the attacks above: corrupting the SPA router's state, flooding the history stack to make navigation unusable, or overwriting the current URL to a path that looks benign in browser history when the user's history is later reviewed.
The defense is sandboxed iframes for tool output rendering, not Content Security Policy alone. CSP with script-src 'none' prevents inline script execution, but if the tool output is rendered as DOM content (via innerHTML without script execution), CSS-based attacks and history.pushState() via event handlers injected through HTML attributes (onclick, onerror) may still execute depending on CSP directives. Rendering tool output inside a sandboxed iframe (sandbox="") is the reliable barrier — sandboxed iframes cannot call history.pushState() on the parent window.
Sandboxed iframe defense against history manipulation
// DANGEROUS: rendering tool output in the main document context
container.innerHTML = toolResult.content; // injected script can call history.pushState()
// CORRECT: render tool output in a sandboxed iframe
function renderToolOutput(content) {
const blob = new Blob([`
<!doctype html><html><head>
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'">
</head><body>
<div id="content"></div>
<script>
// Only render text content — no HTML parsing of tool output
document.getElementById('content').textContent = ${JSON.stringify(content)};
</script>
</body></html>
`], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const iframe = document.createElement('iframe');
// sandbox="" with no tokens = most restrictive: no scripts, no same-origin,
// no popups, no form submission, no pointer lock, no history API access
iframe.sandbox = '';
iframe.src = url;
iframe.style.cssText = 'border:none;width:100%;height:auto';
return iframe;
}
History sniffing via CSS :visited timing
The CSS :visited pseudo-class changes the style of links the user has previously visited. Classic history sniffing exploited the fact that getComputedStyle() returned different color values for visited vs. unvisited links, allowing scripts to probe whether the user had visited specific URLs. Modern browsers now return the same computed style for visited and unvisited links regardless of actual style, defeating this specific technique.
However, timing-based variants remain viable in some scenarios. A high-resolution timing attack probes the rendering latency of a large list of candidate URLs — visited links with distinct :visited styles may render at marginally different speeds depending on paint invalidation behavior. This is highly browser-version dependent and not reliably exploitable in current Chromium, but the fundamental principle (same-origin scripts that render a list of links can infer visit status via timing oracles) persists in research literature.
For MCP UIs, the more practical concern is:
- Tool output that contains a large rendered link list — injected by a prompt injection payload — probing a set of sensitive URLs to infer user identity or prior activity
- Navigation Timing API (
performance.getEntriesByType('navigation')) revealing load times for resources that differ based on cached vs. uncached state — indicating prior visits performance.mark()timing aroundfetch()calls to same-origin endpoints that vary in response time based on authentication state
Navigation Timing API as a history oracle
// Timing oracle: injected script probes which of these URLs the user has cached
// (= previously visited) via resource load latency
const candidateURLs = [
'https://same-origin.app/sensitive-page-1',
'https://same-origin.app/admin/dashboard',
'https://same-origin.app/private/user-123',
];
async function probeVisited(url) {
const start = performance.now();
try {
await fetch(url, { mode: 'no-cors', cache: 'force-cache' });
} catch {}
return performance.now() - start;
}
// Threshold: cache hit < 5ms, network fetch > 50ms
// A timing oracle using the browser's HTTP cache to infer prior visits
This attack requires script execution in the MCP UI context. The primary defense is preventing script execution from tool output — sandboxed iframes and strict CSP. If no injected script can run, no timing oracle can be probed.
History API security controls for MCP server UIs
| Attack surface | Defense | Implementation |
|---|---|---|
| history.pushState() called by injected tool output script | Sandboxed iframes for tool output rendering | sandbox="" iframe with blob URL; no allow-scripts token |
| history.replaceState() hiding attack evidence from navigation history | Same as above; also log tool output rendering events server-side for audit | Server-side structured logging of all tool outputs rendered |
| CSS :visited sniffing via rendered link list | CSP that blocks external style loading; sandboxed iframe with sandbox="allow-same-origin" removed | Strict CSP style-src 'none' prevents :visited style rules from injected stylesheets |
| Navigation Timing API probing via injected script | Block script execution (CSP + sandbox); Performance API is not accessible in fully sandboxed iframes | Sandboxed iframe without allow-scripts token blocks Performance API access |
| SPA router state corruption via pushState state object | Validate router state on popstate events; reject state objects that fail schema check | window.addEventListener('popstate', e => { if (!isValidRouterState(e.state)) history.back(); }) |
SkillAudit findings for browser history security in MCP server UIs
history.pushState() and history.replaceState() to corrupt navigation state, flood history stack, and hide attack evidence from the user's browser historypopstate events — injected history.pushState() calls with malformed state objects corrupt the router on back-navigation, potentially crashing the UI or causing navigation to unintended application statessandbox="allow-scripts allow-same-origin" — allow-same-origin allows the sandboxed iframe to access the parent's history via window.parent.history, defeating the history isolation intent of the sandboxSee also: XSS security · Prompt injection security · CSP deep dive