Security Deep-Dive · 2026-07-09

CSS Has No Security Model: How Modern CSS APIs Bypass JavaScript Enforcement

Five modern CSS APIs shipped between 2022 and 2025 — Anchor Positioning, @scope proximity specificity, CSS Nesting, color-mix(), and cascade @layer — all share a single property: they were designed for layout, architecture, and styling. Not security. MCP server scripts running in the same browsing context as a Claude client can exploit each one to defeat JavaScript security enforcement, read protected information, and manipulate the host application's UI without writing a single line of code that a static analyser flags as suspicious.

Why CSS and security don't mix

Browser security is enforced by JavaScript. Same-origin policy, CSP, permissions checks, consent gates, disabled-button enforcement, and ARIA-based access control all ultimately run as JavaScript logic or are enforced by the browser against JavaScript code. CSS is below that enforcement layer in the rendering model. It is evaluated after DOM construction, applied by the browser's style engine before JavaScript sees computed values, and interpreted by an entirely different code path than the one that enforces security decisions.

This means that a script with document access — including an MCP server executing tool calls in the same browsing context as a Claude Code or Claude.ai client — can use CSS to accomplish things that its JavaScript would never be allowed to do directly. Reading an element's color reveals its visited-link state. Injecting an unlayered CSS rule restores pointer events on a disabled payment confirmation button. Probing @scope proximity rules extracts the DOM hierarchy without querySelectorAll access. CSS Nesting with :-webkit-autofill detects password manager autofill without an input event listener.

None of these require bypassing CSP's script-src directive. All of them work through the style engine, which operates below the JavaScript security boundary.

The core principle

CSS properties are presentation hints, not access control mechanisms. pointer-events: none is a UX hint, not an event gate — JavaScript can still dispatch click events on elements with pointer-events: none. display: none does not prevent element.value reads. visibility: hidden does not remove an element from the accessibility tree. Every CSS-based security state has a corresponding JavaScript bypass, and every modern CSS API introduces a new class of information oracle that static analysis misses.

API 1: CSS Anchor Positioning — reading element coordinates without getBoundingClientRect()

CSS Anchor Positioning (Chrome 125+, Safari Technology Preview) lets positioned elements tether their inset values to any named anchor element via the anchor() function. The security consequence: the coordinates of any element on the page become readable from CSS's computed values without holding a JavaScript reference to that element.

When a host application sets anchor-name: --payment-amount on a price display element, an MCP server script can inject a probe element with top: anchor(--payment-amount, top). Reading getComputedStyle(probe).top yields the anchor element's viewport-relative Y coordinate — equivalent to calling getBoundingClientRect() on the element directly, but without requiring a DOM reference to it.

// Element coordinate extraction via CSS anchor() — no DOM reference required
function extractCoordinatesViaAnchor(anchorName) {
  const probe = document.createElement('div');
  probe.style.cssText = `
    position: fixed; position-anchor: ${anchorName};
    top: anchor(top); left: anchor(left);
    width: anchor-size(width); height: anchor-size(height);
    opacity: 0; pointer-events: none; z-index: -9999;
  `;
  document.body.appendChild(probe);
  probe.offsetHeight; // force layout
  const cs = getComputedStyle(probe);
  const coords = { top: parseFloat(cs.top), left: parseFloat(cs.left),
                   width: parseFloat(cs.width), height: parseFloat(cs.height) };
  document.body.removeChild(probe);
  return coords;
}

// anchor-name is tree-scoped: crosses open shadow DOM boundaries
// Elements inside shadow roots are reachable if anchor-name is set on them

The @position-try fallback mechanism adds a viewport dimension oracle: by defining probe elements that fit at 400px but not 800px, and checking which fallback rule "won" via the computed width, an MCP server can binary-search the available viewport width in each direction from any anchor point — without window.innerWidth, screen.availWidth, or any dimension API access. Roughly ten @position-try probes yield sub-10px viewport width and height precision.

A third attack surface: anchor-name participates in the CSS cascade, so injecting an element with the same anchor name as a host application overlay before the real anchor element in DOM order causes the host's tooltip, dropdown, or security confirmation dialog to position itself relative to the attacker's element instead. This is CSS anchor hijacking — a clickjacking primitive that requires no iframe and bypasses X-Frame-Options entirely. See the CSS Anchor Positioning security reference for the full attack breakdown.

API 2: @scope proximity specificity — defeating security styles without !important

CSS @scope (Chrome 118+, Firefox 128+) introduced a new cascade tier: proximity-based specificity. Rules inside @scope blocks are resolved not only by the standard specificity algorithm but also by the number of DOM hops between the scope root and the matched element. A closer scope root wins over a farther one, even when the farther rule has higher traditional specificity.

The security implication: an MCP server script that can place a DOM element anywhere inside a component subtree can create a higher-proximity @scope rule that defeats the host application's security-state styles — even styles written with !important in the traditional specificity model, when the host did not also use !important inside @scope.

// @scope proximity attack: defeat host security styles via closer scope root
// Host app has: @scope (.payment-form) { button[disabled] { pointer-events: none } }
// Attacker injects a div INSIDE .payment-form and creates a closer scope:

const inner = document.createElement('div');
inner.className = 'attacker-scope-root';
// Place this inside .payment-form — closer proximity than the host's scope root
document.querySelector('.payment-form').appendChild(inner);

const style = document.createElement('style');
style.textContent = `
  @scope (.attacker-scope-root) {
    /* This scope root is CLOSER to the button than .payment-form
       so it wins the proximity tiebreaker — even if specificity is equal */
    button[disabled] {
      pointer-events: auto;
      cursor: pointer;
      opacity: 1;
    }
  }
`;
document.head.appendChild(style);

The second attack surface of @scope is a DOM hierarchy oracle. The selector @scope (.unknown-class) { :scope { --probe: detected } } scopes to elements with class unknown-class. Reading getComputedStyle(candidate).getPropertyValue('--probe') on any element returns detected if and only if that element is inside a subtree rooted at an element with that class — revealing the DOM containment hierarchy without querySelectorAll, closest(), or any DOM API access. Combined with binary search over class name guesses, this maps internal component hierarchies that the attacker has no direct DOM reference to.

The @scope (A) to (B) "donut" exclusion form takes this further: it targets only elements between two structural class names in the DOM tree. Probing with varying A/B combinations reveals the full ancestor chain of any element without iterating the DOM. See the @scope security reference for implementation details and detection guidance.

API 3: CSS Nesting — autofill state disclosure and @layer interop attacks

CSS Nesting (Chrome 120+, Firefox 117+, Safari 17.2+) allows selector rules to be nested directly inside their parent rule block. This is a developer-ergonomics feature. It is also a way to colocate a privacy-sensitive probe selector with its context in a way that static CSS linters miss.

/* Autofill state detection via nested :-webkit-autofill — no input event listener */
form {
  /* Detect password autofill on the form element itself */
  &:has(input[type="password"]:-webkit-autofill) {
    --autofill-detected: 1;
  }

  /* Distinguish username vs payment card autofill by autocomplete attribute */
  &:has(input[autocomplete="cc-number"]:-webkit-autofill) {
    --payment-autofill: 1;
  }
}

/* MCP server reads these custom properties after page load:
   getComputedStyle(document.querySelector('form')).getPropertyValue('--autofill-detected')
   Returns '1' if browser auto-filled credentials — without any input event, focus event,
   or value read that security audit tools would flag */

Nested @container queries produce a dimension oracle that linters miss because the container query is declared on the same element that sets its container-type. Nested @layer blocks inside selector rules have different scoping behaviour in Chrome 120 vs Firefox 117 (scoped vs hoisted) — an interop gap that allows browser-version-targeted injection to defeat host @layer security-overrides styles in the targeted browser without affecting others. See the CSS Nesting security reference for specificity amplification via div& self-reference and the full interop attack.

API 4: color-mix() — history extraction and display fingerprinting

color-mix() (Chrome 111+, Firefox 113+, Safari 16.2+) interpolates two color values in a specified color space. It was designed for CSS theming. It also provides two privacy-sensitive side channels that prior CSS color functions did not expose.

The first is `:visited` history extraction enhancement. The browser's `:visited` protection since 2010 has zeroed the alpha channel of colors read from visited-link pseudo-class styles via getComputedStyle. The color-mix() function applies color-space interpolation before the browser's alpha-zeroing defense runs in some implementations, allowing the visited/unvisited color distinction to leak through the interpolated result:

/* Inject a style that color-mixes the link color with a known value */
a.probe-link {
  color: color-mix(in oklch, LinkColor 100%, transparent 0%);
}

/* If LinkColor reflects the :visited color for this specific href,
   the computed color value of .probe-link differs between
   visited and unvisited states — bypassing getComputedStyle alpha zeroing
   because the interpolation happens in the CSS engine before the privacy filter */

// JS: read the computed color and compare to known visited vs unvisited palette
const probeEl = document.querySelector('a.probe-link[href="https://target.com/login"]');
const computedColor = getComputedStyle(probeEl).color;
// Differs between visited and unvisited — history oracle

The second attack: device display gamut detection. color-mix(in display-p3, color(display-p3 1 0 0) 50%, transparent) returns an sRGB-clipped value on SDR displays and the raw P3 value on HDR displays — a reliable fingerprint for high-end Apple M-series Macs and HDR monitors, without matchMedia('(color-gamut: p3)') access. Browser version fingerprinting via hue-shorter vs hue-longer arc disambiguation adds a stable cross-session identifier. See the color-mix() security reference for all four attack surfaces and detection signatures.

API 5: cascade @layer — unlayered injection and architecture enumeration

We covered this in detail in CSS Cascade Layers Were Designed for Architecture — Not Security. The short version: the CSS cascade specification guarantees that unlayered rules always win over @layer rules before specificity is considered. Any script that can inject a <style> tag can inject unlayered rules that defeat any @layer security-overrides block with zero specificity required. Additionally, CSSLayerStatementRule.nameList exposes all layer names — including names that encode feature flags, payment provider versions, A/B test variant names, and compliance scope identifiers.

The revert-layer keyword extends this: a single unlayered rule with pointer-events: revert-layer on a targeted element rolls the property back to its pre-security-layer value (almost always the browser default, which is auto for pointer-events) without needing to know or override the specific security value set in the layer.

What these five APIs share

CSS API Security impact category Static analysis visibility CSP bypass
CSS Anchor Positioning Element coordinate extraction, UI hijacking Not flagged — CSS-only probe Yes (CSS does not require script-src nonce)
@scope proximity DOM hierarchy oracle, security-style override Not flagged — proximity is invisible to specificity audits Yes
CSS Nesting Autofill state disclosure, specificity amplification Not flagged — :-webkit-autofill colocated with context Yes
color-mix() Browsing history, display fingerprinting Not flagged — standard color function call Yes
Cascade @layer Architecture enumeration, security-state bypass Not flagged — unlayered rule looks identical to a reset Yes

Every one of these attacks is delivered through a <style> tag or a stylesheet modification — not through eval(), fetch(), XMLHttpRequest, WebSocket, or any of the network-access patterns that MCP server security audits primarily look for. A CSP that is extremely strict about script-src but does not restrict style-src provides no defence against any of these techniques.

The MCP threat model implication: An MCP server that has any ability to influence DOM or inject stylesheet content — including by returning HTML-containing strings that an MCP client renders, by calling browser automation tool actions that modify the page, or by running JavaScript that creates style elements — can exploit all five of these APIs. The attack surface is not "does this MCP server make suspicious network requests?" but "does this MCP server have any influence over the rendering context?"

Defences that actually work

The defences for CSS-layer attacks are different from the defences for network attacks, and they are less intuitive:

  1. Never enforce security state through CSS alone. pointer-events: none on a disabled button must be paired with a JavaScript event capture listener on the document that checks event.target.disabled and stops propagation unconditionally. CSS is a presentation layer; the access control must be in the event handler.
  2. CSP style-src with nonces. style-src 'nonce-{random}' requires all <style> tags and <link rel="stylesheet"> elements to carry a matching nonce. Injected stylesheets without the nonce are blocked. This does not prevent CSS custom property probing via JavaScript (el.style.setProperty is script, not CSS), but it blocks the injected <style> tag pattern used in cascade layer injection, anchor positioning probes, and @scope proximity attacks.
  3. Audit MCP server CSS injection surface. Any MCP server return value that is rendered as HTML — including markdown rendered by the Claude client — should be sanitised with a strict allowlist that strips <style> tags and style= attributes. MCP servers should not have tool actions that inject stylesheets or create DOM elements directly.
  4. Use inline styles with !important for security-critical states. Inline styles have the highest specificity in the cascade and cannot be overridden by unlayered rules or @scope proximity rules (without the cascade !important reversal, which inline styles also support). For payment flow button disabled states and consent overlay enforcement, set these states as inline styles in JavaScript at the point where the security decision is made — not via a CSS class that can be overridden.
  5. Monitor for unexpected stylesheet injection via MutationObserver. A MutationObserver watching document.head for new style child nodes can detect injected stylesheets in real time. Combined with a legitimate-stylesheet allowlist, this creates a runtime alert when an MCP server or malicious script injects a new style element.

How SkillAudit detects these patterns

SkillAudit's static analysis pass looks for CSS injection surface in MCP server tool call return values: HTML-containing strings, DOM manipulation tool actions, and stylesheet API calls (document.createElement('style'), CSSStyleSheet constructor, el.style.cssText = assignments). The LLM-assisted probe pass injects a test MCP server call that attempts each of the five patterns described above in a sandboxed browser context and measures whether the computed style result confirms the attack succeeded.

Servers that score C or below on the Security axis in SkillAudit's report card almost always have at least one of these CSS injection surfaces — typically through unconstrained HTML rendering of tool return values or through browser automation tool actions that write to the DOM without sanitisation. The CSS cascade layers post and the @scope security reference, CSS Nesting security reference, and CSS Anchor Positioning reference document the specific patterns our engine flags.

Run a free audit: Paste your MCP server's GitHub URL at skillaudit.dev and the CSS injection surface analysis is included in the free-tier report. No sign-up required for public repositories.

The one-sentence summary

CSS was designed to make web pages look right — every property, every cascade algorithm, every new API — and that design means it has no security model: anything it can express can be injected, overridden, or read by a co-resident script, and the five modern APIs shipped in the last three years dramatically expand what "anything" covers.