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:

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 surfaceDefenseImplementation
history.pushState() called by injected tool output scriptSandboxed iframes for tool output renderingsandbox="" iframe with blob URL; no allow-scripts token
history.replaceState() hiding attack evidence from navigation historySame as above; also log tool output rendering events server-side for auditServer-side structured logging of all tool outputs rendered
CSS :visited sniffing via rendered link listCSP that blocks external style loading; sandboxed iframe with sandbox="allow-same-origin" removedStrict CSP style-src 'none' prevents :visited style rules from injected stylesheets
Navigation Timing API probing via injected scriptBlock script execution (CSP + sandbox); Performance API is not accessible in fully sandboxed iframesSandboxed iframe without allow-scripts token blocks Performance API access
SPA router state corruption via pushState state objectValidate router state on popstate events; reject state objects that fail schema checkwindow.addEventListener('popstate', e => { if (!isValidRouterState(e.state)) history.back(); })

SkillAudit findings for browser history security in MCP server UIs

CRITICAL −22Tool output rendered as innerHTML in the main document context — injected scripts from prompt injection payloads can call history.pushState() and history.replaceState() to corrupt navigation state, flood history stack, and hide attack evidence from the user's browser history
HIGH −18No sandboxed iframe for tool output rendering — tool output HTML executes in same-origin context with full access to History API, Performance API, and timing side channels; any script injected via prompt injection can probe navigation state and history
HIGH −16SPA router does not validate state object on popstate 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 states
MEDIUM −12Tool output rendered inside an iframe that uses sandbox="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 sandbox
MEDIUM −10No server-side audit log of tool outputs rendered in the UI — history.replaceState() attacks that hide navigation evidence are undetectable without server-side record of which tool outputs were displayed in which session
MEDIUM −8Navigation Timing API accessible to tool output iframe (allow-scripts without Performance API restriction) — injected scripts can probe cache-based timing oracles to infer which same-origin URLs the user has previously visited

See also: XSS security · Prompt injection security · CSP deep dive

Run a free SkillAudit on your MCP server →