MCP Server Security · Browser Storage · DoS · Cross-Tab

MCP server storage quota security — localStorage DoS, IndexedDB quota exhaustion, storage persistence API, and cross-tab storage event injection in MCP UIs

Browser storage APIslocalStorage, sessionStorage, and IndexedDB — are widely used by MCP UIs to cache tool output, store conversation history, and persist session state across reloads. The security problem is that these storage buckets are shared across all same-origin tabs and have per-origin quotas. An MCP tool that returns oversized output can exhaust the localStorage quota (typically 5 MB per origin), breaking all same-origin tabs. Storage events fired on write are visible to all tabs, turning a compromised tool response into a cross-tab injection channel.

1. localStorage quota exhaustion DoS

localStorage has a per-origin quota of approximately 5 MB in most browsers (2.5 MB per origin in some mobile browsers). The quota is shared across all data stored by all scripts on the same origin. When a write would exceed the quota, localStorage.setItem() throws a DOMException with name QuotaExceededError. If this exception is not caught, JavaScript execution halts at that point and any state that was being written is lost.

For MCP UIs that store tool output in localStorage (for history persistence), an MCP server that returns a single large response can exhaust the quota:

// Attack: MCP tool returns a response large enough to exhaust localStorage quota
// A tool returning a 4 MB JSON blob, repeated twice, fills a 5 MB origin quota

// VULNERABLE: unchecked localStorage write
async function storeToolOutput(toolName: string, output: unknown) {
  const serialized = JSON.stringify(output);
  // If output is 4 MB and localStorage already has 2 MB, this throws
  // QuotaExceededError — unhandled, it crashes the calling function
  localStorage.setItem(`tool_output_${toolName}_${Date.now()}`, serialized);
}

// SECURE: check size before write, enforce a per-entry cap
const MAX_STORAGE_ENTRY_BYTES = 50_000;  // 50 KB per tool output entry

async function storeToolOutput(toolName: string, output: unknown) {
  const serialized = JSON.stringify(output);
  if (serialized.length > MAX_STORAGE_ENTRY_BYTES) {
    // Store a truncated summary or reference, not the full payload
    console.warn(`Tool output for ${toolName} exceeds storage limit; storing summary only`);
    localStorage.setItem(`tool_output_${toolName}_${Date.now()}`, JSON.stringify({
      truncated: true,
      preview: serialized.slice(0, 200),
      byteLength: serialized.length,
    }));
    return;
  }
  try {
    localStorage.setItem(`tool_output_${toolName}_${Date.now()}`, serialized);
  } catch (e) {
    if (e instanceof DOMException && e.name === 'QuotaExceededError') {
      // Evict oldest entries and retry once
      evictOldestEntries();
      try { localStorage.setItem(`tool_output_${toolName}_${Date.now()}`, serialized); }
      catch { /* storage unavailable; degrade gracefully */ }
    }
  }
}

2. IndexedDB quota exhaustion

IndexedDB has a much larger quota than localStorage — typically up to 50% of available disk space per origin — but it is still bounded. More importantly, IndexedDB write failures from quota exhaustion are asynchronous: the write returns a rejected promise or fires an error event on the transaction. MCP UIs that use IndexedDB for large tool output (file contents, embeddings, vector store results) often don't handle the quota rejection path, leaving the database in an inconsistent state when quota is hit.

Server-side mitigation: enforce an output size limit per tool call before the response is sent. A tool that needs to return large data should return a reference (a URL or an object ID) and let the client fetch the data through a separate, intentionally large-data endpoint — not through the MCP tool response channel, which is not designed for multi-MB payloads.

// MCP server — enforce output size limit before returning tool result
const MAX_TOOL_OUTPUT_BYTES = 100_000;  // 100 KB per tool response

function enforceOutputSizeLimit(result: unknown, toolName: string): unknown {
  const serialized = JSON.stringify(result);
  if (serialized.length > MAX_TOOL_OUTPUT_BYTES) {
    // Store the large result server-side, return a reference
    const resultId = storeTemporaryResult(serialized);
    return {
      type: 'large_result_reference',
      resultId,
      byteLength: serialized.length,
      fetchUrl: `/results/${resultId}`,
      expiresAt: new Date(Date.now() + 3600_000).toISOString(),
    };
  }
  return result;
}

3. Cross-tab storage event injection

The storage event fires in all same-origin tabs when localStorage is written from a different tab. MCP UIs that use the storage event to coordinate state across tabs (e.g., to share session tokens, sync conversation history, or broadcast tool output to a viewer tab) are vulnerable to injection via storage writes from any compromised same-origin context.

// VULNERABLE: trusting storage events as authoritative cross-tab messages
window.addEventListener('storage', event => {
  if (event.key === 'mcp_tool_output_latest') {
    // If a malicious script on any same-origin page writes to this key,
    // this handler processes it as if it were a legitimate tool output
    const toolOutput = JSON.parse(event.newValue);
    renderToolOutput(toolOutput);  // XSS if output contains HTML and renderToolOutput uses innerHTML
  }
});

// SECURE: validate the structure and sanitize; never use innerHTML on storage event data
window.addEventListener('storage', event => {
  if (event.key !== 'mcp_tool_output_latest') return;
  try {
    const raw = JSON.parse(event.newValue ?? 'null');
    // Validate schema — reject unexpected shapes
    const output = toolOutputSchema.parse(raw);
    // Sanitize before DOM insertion — never innerHTML on external data
    renderToolOutputSafe(output);  // uses textContent or a sanitizer
  } catch {
    // Invalid storage event data; discard silently
  }
});

4. Storage persistence API fingerprinting

The Storage Persistence API (navigator.storage.persist()) allows a site to request that the browser not evict its storage under storage pressure. If an MCP UI requests persistent storage and the browser grants it (which it does automatically for installed PWAs and sites the user has engaged with), the site's storage survives device storage pressure that would normally clear other sites' data.

The security concern: navigator.storage.estimate() can be called by any script on the page to learn the total quota and current usage for the origin. In multi-tenant deployments where the MCP UI is on a shared origin, one tenant's script can observe how much storage other tenants are using — a side channel for detecting usage patterns.

// Information disclosure: attacker script observes storage usage
const estimate = await navigator.storage.estimate();
console.log({
  quota: estimate.quota,   // 5.36 GB (device capacity fraction)
  usage: estimate.usage,   // 4.2 MB (how much this origin is using)
  // Indirectly reveals: how many conversation turns have been stored,
  // how many tool outputs are cached, etc.
});

In shared-origin multi-tenant deployments, avoid storing per-tenant data in localStorage or IndexedDB at all — use server-side storage keyed by authenticated session. Each tenant gets their own API context, not shared browser storage.

Storage API security comparison

Storage APIQuotaPersistenceCross-tab visibilityMCP-specific risk
localStorage~5 MB/originUntil clearedYes (storage event)Quota DoS; cross-tab injection via storage event
sessionStorage~5 MB/originTab lifetime onlyNoQuota DoS; lost on tab close (less persistent data exfil)
IndexedDB~50% diskUntil clearedNo (no storage event)Quota DoS for large tool outputs; async error handling gaps
Cache API~50% disk (shared with IDB)Until clearedNoService worker cache poisoning (see service worker page)
Cookies4 KB/cookieConfigurableNo (no event)Session fixation; SameSite enforcement; Secure flag

SkillAudit findings for storage quota security

Critical MCP server tool responses have no output size limit; a single tool call can return a payload large enough to exhaust the client's localStorage or IndexedDB quota. −22 pts
High MCP UI storage event listener processes event data without schema validation or sanitization; injection via any same-origin storage write causes XSS or state corruption. −18 pts
High localStorage.setItem() calls in MCP UI do not handle QuotaExceededError; quota exhaustion causes unhandled exception and data loss during active tool sessions. −16 pts
Medium Per-tenant MCP session data stored in shared-origin localStorage; navigator.storage.estimate() exposes usage patterns as a cross-tenant side channel. −12 pts
Medium IndexedDB transaction error events on quota exhaustion are not handled; database left in inconsistent state and subsequent writes silently fail. −10 pts
Medium No per-entry size cap on localStorage writes; a single large tool output can exhaust quota even when the per-session total is otherwise reasonable. −8 pts

SkillAudit checks MCP server source for missing output size limits and scans MCP UI bundles for unhandled QuotaExceededError paths and unsanitized storage event handlers. Audit your MCP server →