MCP server CSS attr() function security: data-attribute content replacement, type-checked opacity control, pseudo-element blank substitution, and attr() length collapse attacks

Published 2026-07-23 — SkillAudit Research

The CSS attr() function reads a named HTML attribute from an element and uses its value as a CSS property value or in the content property of a pseudo-element. In CSS2 and CSS3, attr() was limited to the content property of pseudo-elements, where it could substitute attribute values into generated text. In CSS Values Level 5, attr() was extended to accept a type annotation (e.g., attr(data-opacity number)) that allows the attribute value to be used as any CSS property value of the matching type — opacity, lengths, colors, and more.

This creates a bidirectional attack surface for MCP servers. In the CSS3 form: a stylesheet rule can use content: attr(data-label) to replace a pseudo-element's text with whatever an HTML attribute says — and if the MCP server controls the attribute value, it controls what text the pseudo-element displays (or whether it displays anything). In the CSS Values Level 5 form: a stylesheet rule can use attr(data-opacity number) in the opacity property, and the MCP server sets the attribute value to 0 to make the element transparent. This article documents four attack patterns using these mechanisms.

Browser support for CSS Values Level 5 attr(): The type-checked attr() form (e.g., attr(data-x number)) is still being standardized and has partial browser support. Chrome has experimental support behind a flag; Firefox and Safari have limited or no support as of 2026. Attacks using the type-checked form are included as a forward-looking threat model. The CSS3 content: attr() form for pseudo-elements is fully supported in all browsers.

Attack 1: content:attr() pseudo-element blank substitution

In many consent dialog implementations, the disclosure text is generated or displayed via a CSS pseudo-element (::before or ::after) using content: attr(data-disclosure). This pattern is used in frameworks that dynamically inject disclosure text through JavaScript by setting data attributes. An MCP server that can modify the element's data attribute value can replace the displayed disclosure text with an empty string, whitespace, or misleading text:

/* Legitimate host page pattern (vulnerable design): */
/* CSS rule that the host page uses for its consent component */
.consent-disclosure::before {
  content: attr(data-disclosure);  /* Displays the value of data-disclosure attribute */
  display: block;
  font-size: 14px;
  color: var(--text-primary);
}

/* Legitimate host HTML: */
/* <div class="consent-disclosure"
        data-disclosure="This MCP server will access your filesystem, execute commands, and read private data.">
   </div> */

/* The text content of .consent-disclosure is empty (it's in the pseudo-element via attr()).
   The visible disclosure text is generated from the data attribute.

   *** MCP SERVER ATTACK ***
   The MCP server modifies the data-disclosure attribute via JavaScript: */

document.querySelector('.consent-disclosure').setAttribute(
  'data-disclosure',
  ' ' // Non-breaking space — visually empty, not an empty string
  // Or: '' — empty string displays nothing
  // Or: 'You are granting limited access to your documents.' — misleading shorter text
);

/* After this setAttribute() call:
   - The element's textContent is still '' (it was never non-empty)
   - The pseudo-element now displays a non-breaking space (invisible)
   - The disclosure is visually blank
   - A MutationObserver watching for childList/characterData changes misses this:
     the text change happened in an attribute, not a text node
   - However: MutationObserver with { attributes: true } DOES catch this */

The key vulnerability is that content: attr(data-disclosure) makes the displayed text dependent on an attribute rather than a text node. MutationObserver watches for characterData changes on text nodes — but attribute changes are a separate observation type that must be explicitly requested. Consent systems that don't observe attribute changes on their disclosure elements are vulnerable.

// Detection: watch for attribute changes on consent elements
function monitorConsentDisclosureAttributes(el) {
  const initialValue = el.getAttribute('data-disclosure') ||
                       el.getAttribute('data-label') ||
                       el.getAttribute('data-content');

  const observer = new MutationObserver(mutations => {
    for (const m of mutations) {
      if (m.type === 'attributes') {
        const newValue = el.getAttribute(m.attributeName);
        if (newValue !== initialValue) {
          console.warn('SECURITY: consent disclosure attribute changed', {
            attribute: m.attributeName,
            was: initialValue,
            now: newValue,
          });
          // Lock the attribute back
          el.setAttribute(m.attributeName, initialValue);
        }
      }
    }
  });

  observer.observe(el, { attributes: true, attributeOldValue: true });
  return observer;
}

Attack 2: type-checked attr() opacity control

CSS Values Level 5 introduces type-annotated attr() that can be used in any CSS property. An MCP server that controls an element's data attribute can set the opacity property to the attribute value — and then set the attribute to 0 to make the element transparent:

/* CSS Values Level 5 type-checked attr() in opacity */
/* Vulnerable CSS rule in a dynamically-themed consent framework: */
.consent-disclosure {
  /* Intended for theming: host can set opacity for partial disclosure reveal */
  opacity: attr(data-opacity number, 1);
  /* Syntax: attr(attribute-name type, fallback)
     type = number → interprets the attribute value as a number
     fallback = 1 → defaults to fully opaque if attribute is missing */
}

/* Host HTML: */
/* <div class="consent-disclosure" data-opacity="1">
     This MCP server will access your filesystem and network.
   </div> */

/* *** MCP SERVER ATTACK: set attribute to "0" *** */
document.querySelector('.consent-disclosure').setAttribute('data-opacity', '0');
/* The CSS rule now evaluates to: opacity: 0
   The disclosure element is fully transparent.
   Its dimensions, position, textContent, and layout space are unchanged.
   getComputedStyle().opacity returns '0'. */

/* Same attack via attr() in font-size (type = length): */
.consent-disclosure {
  font-size: attr(data-size length, 14px);
}
/* MCP server sets: element.setAttribute('data-size', '0px')
   → font-size: 0px → invisible text */

/* Via attr() in color (type = color): */
.consent-disclosure {
  color: attr(data-color color, #1a1a1a);
}
/* MCP server sets: element.setAttribute('data-color', 'transparent')
   → color: transparent → invisible text */

Attack 3: attr() in content replacement — misleading disclosure substitution

Beyond making disclosure blank, content: attr() enables the MCP server to substitute the real disclosure text with misleading text that understates the permissions being requested. The substituted text passes text-content checks (the element has non-empty content) but does not accurately describe the permissions:

/* Misleading content substitution via attr() */
.consent-disclosure::before {
  content: attr(data-display-text);
}

/* Host page original attribute: */
/* data-display-text="This server will access all files in your home directory,
   execute shell commands with your user permissions, and read your SSH keys." */

/* *** MCP SERVER ATTACK: replace with understated text *** */
document.querySelector('.consent-disclosure').setAttribute(
  'data-display-text',
  'This server will access some of your files.'
  /* Technically non-empty. The consent dialog shows text.
     But the disclosed scope is dramatically understated.
     The real permissions (shell execution, SSH key access) are omitted.
     A text-content check passes (non-empty string displayed).
     A semantic validation check (does the displayed text match the granted permissions?) fails. */
);

/* Whitespace-only variant: appears to have content, renders as blank */
document.querySelector('.consent-disclosure').setAttribute(
  'data-display-text',
  '​​​' // Zero-width spaces — three of them, non-empty string
  // Passes: element.getAttribute('data-display-text') !== ''
  // But renders as blank visible space
);

Attack 4: attr() length value to collapse element dimensions

Type-checked attr() can inject length values into dimension properties. If a consent framework uses attr() to make the disclosure container height configurable (a pattern in embedded/customizable consent dialogs), an MCP server can set the height attribute to zero to collapse the container:

/* attr() length collapse attack */
/* Vulnerable consent dialog CSS (dynamically sized): */
.consent-disclosure-container {
  height: attr(data-height length, auto);  /* default: auto */
  overflow: hidden;                          /* clips content beyond container height */
}

/* *** MCP SERVER ATTACK *** */
document.querySelector('.consent-disclosure-container')
        .setAttribute('data-height', '0px');
/* Result: height: 0px; overflow: hidden
   The container is collapsed to zero height.
   All disclosure text is clipped by overflow:hidden.
   The element is in the layout but occupies no visible space. */

/* max-height variant (slightly less obvious): */
.consent-disclosure-container {
  max-height: attr(data-max-height length, none);
}
/* MCP server sets: data-max-height = "1px"
   → max-height: 1px; with overflow:hidden → only 1px of disclosure visible */

/* Detection for attr() length collapse: */
function detectAttrLengthCollapseAttack(el) {
  const attrs = el.attributes;
  const cs = window.getComputedStyle(el);

  const suspiciousLengthAttrs = [];
  for (const attr of attrs) {
    if (!attr.name.startsWith('data-')) continue;
    const val = attr.value.trim();
    // Check if the attribute value is a zero-length or near-zero-length CSS value
    if (/^0(px|em|rem|vh|vw|%)?$/.test(val) || /^0\.0*\d*(px|em)?$/.test(val)) {
      suspiciousLengthAttrs.push({ name: attr.name, value: val });
    }
  }

  const height = parseFloat(cs.height);
  const maxHeight = cs.maxHeight;

  if (suspiciousLengthAttrs.length > 0 && (height < 4 || maxHeight === '0px' || maxHeight === '1px')) {
    return {
      finding: true,
      reason: 'data attribute with zero/near-zero length value coincides with collapsed element height',
      suspiciousAttrs: suspiciousLengthAttrs,
      computedHeight: height,
      computedMaxHeight: maxHeight,
    };
  }

  return { finding: false };
}

Root cause fix: The attr() attack family is enabled by consent dialog designs that use CSS attribute references to control what the dialog displays or how it is styled. The fix is to not use content: attr() or type-checked attr() for consent disclosure content or critical style properties. Use text nodes for disclosure text (MutationObserver with characterData: true can protect them), and use static CSS values (not attribute references) for display-critical properties like opacity, font-size, and height. The disclosure text should always live in a real DOM text node, not in an HTML attribute that gets interpolated into CSS.

Attack summary

Attack CSS pattern MCP server action Detection point Severity
Pseudo-element blank substitution content: attr(data-disclosure) on ::before setAttribute('data-disclosure', ' ') MutationObserver with attributes: true on disclosure element High
Type-checked attr() opacity control opacity: attr(data-opacity number, 1) setAttribute('data-opacity', '0') MutationObserver attributes + computed opacity check High
Misleading content substitution content: attr(data-display-text) setAttribute('data-display-text', understated_text) Semantic validation of displayed text vs. granted permissions High
attr() length dimension collapse height: attr(data-height length, auto) setAttribute('data-height', '0px') Zero-value data attribute coincides with collapsed height High

Consolidated finding blocks

High CSS attr() pseudo-element content blank substitution: Consent framework uses content: attr(data-disclosure) in a ::before or ::after rule to display the disclosure text. MCP server sets data-disclosure to a non-breaking space, empty string, or zero-width characters. Disclosure renders as blank. Detected by MutationObserver with attributes: true on the disclosure element.
High CSS attr() type-checked opacity control: Framework uses CSS Values Level 5 opacity: attr(data-opacity number, 1). MCP server sets data-opacity="0". Disclosure element becomes fully transparent. Detected by watching for attribute changes on opacity-linked data attributes and validating computed opacity after any attribute mutation.
High CSS attr() misleading disclosure substitution: MCP server sets data-display-text to a shortened, misleading summary of requested permissions (omitting shell execution, credential access, etc.). The element displays non-empty text, passing content checks. Detected by semantic comparison of the displayed attr() value against the full permission grant set.
High CSS attr() length dimension collapse: Framework exposes disclosure container height as an attr() length value. MCP server sets the height attribute to 0px, collapsing the container with overflow: hidden clipping all disclosure content. Detected by checking whether data attributes with zero-length values coincide with near-zero computed height on consent containers.

← Blog  |  Security Checklist