CSS Pseudo-Element Attacks on MCP Consent Disclosures: ::first-line, ::first-letter, and ::placeholder Precision Targeting of the Only Visible Content

Published 2026-07-25 — SkillAudit Research — 18 min read

The most sophisticated CSS consent-suppression attacks in MCP servers are not broad — they are surgical. Rather than hiding the entire consent element (which standard visibility audits detect), they target only the specific portion of the consent text that happens to be visible: the first line of an overflow-clipped panel, the opening capital letter of a legal reference, or the placeholder text in an email input.

Three CSS pseudo-elements — ::first-line, ::first-letter, and ::placeholder — are designed specifically for this kind of precision targeting. Each one applies styles to a structurally specific, non-DOM subset of an element's rendering. And each one shares a critical property: their styles are invisible to getComputedStyle(element). Querying the element returns the element's own styles. To see the pseudo-element's styles, you must call getComputedStyle(element, '::first-line'), getComputedStyle(element, '::first-letter'), or getComputedStyle(element, '::placeholder') with the pseudo-element selector as the second argument.

This two-argument form is rarely used in automated auditing tools. The result is a systematic blind spot: 12 distinct attacks across three pseudo-elements that DOM-based consent audits consistently miss.

Contents

  1. The Systematic Detection Gap
  2. Part 1: ::first-line Attacks — The Overflow Clip Target
  3. Part 2: ::first-letter Attacks — The Opening Capital Target
  4. Part 3: ::placeholder Attacks — The Input Disclosure Target
  5. Unified Pseudo-Element Consent Auditor
  6. GDPR and WCAG Compliance Analysis

The Systematic Detection Gap

Before covering the attacks, it is worth understanding precisely why standard auditing misses them. A typical consent audit pipeline looks like this:

// Standard consent audit — misses all pseudo-element attacks
function standardConsentAudit() {
  const consentEls = document.querySelectorAll(
    '[class*="consent"], [data-consent], #consent-panel, .disclosure'
  );

  consentEls.forEach(el => {
    const cs = window.getComputedStyle(el);

    // Checks that pass even under active attack:
    const checks = {
      display: cs.display,           // 'block' ✓ (element not hidden)
      visibility: cs.visibility,     // 'visible' ✓ (element not hidden)
      opacity: cs.opacity,           // '1' ✓ (element fully opaque)
      color: cs.color,               // '#1a1a1a' ✓ (text color looks correct)
      fontSize: cs.fontSize,         // '14px' ✓ (font size looks correct)
      height: cs.height,             // '80px' ✓ (element has height)
      textContent: el.textContent,   // full legal text ✓ (DOM text present)
      isVisible: !!(el.offsetWidth && el.offsetHeight)  // ✓ (has dimensions)
    };

    // All checks pass even when:
    // - ::first-line { color: transparent } → first line invisible
    // - ::first-letter { font-size: 100vw } → first letter pushes text below fold
    // - ::placeholder { opacity: 0 } → consent placeholder invisible
    //
    // The element's own computed style is unaffected by pseudo-element rules.
    // This is the gap.
  });
}

// What's needed — pseudo-element aware audit:
function pseudoElementAudit(el) {
  const pseudos = ['::first-line', '::first-letter', '::placeholder'];
  pseudos.forEach(pseudo => {
    const ps = window.getComputedStyle(el, pseudo);
    // Now ps reflects the pseudo-element's styles — not the element's
  });
}

The gap is simple: getComputedStyle(el) returns the element's own styles. Pseudo-element styles require the two-argument form. Automated audits almost universally use the one-argument form.

Why pseudo-elements are uniquely suited for consent attacks

Three properties make pseudo-elements ideal for consent suppression: (1) their styles are queried differently from element styles, creating an auditing blind spot; (2) they do not alter DOM text content, so accessibility-tree-based audits find the consent text present; (3) their scope is structurally bounded — ::first-line matches exactly the overflowed line, ::first-letter matches exactly the first character, ::placeholder matches exactly the placeholder attribute text. An attacker does not need to target the entire element; they target only the visible portion.

Part 1: ::first-line Attacks — The Overflow Clip Target

A common consent UI pattern uses a fixed-height container with overflow: hidden. The full consent text is present in the DOM and extends below the container's visible area. Only the first line (or first few lines) of consent text is visible within the container's height. This pattern is sometimes used for "click to expand" consent panels or compact inline consent disclosures.

The ::first-line pseudo-element matches precisely the text on the first formatted line of a block element — the text that happens to be visible in an overflow-clipped container. This makes it the most precise possible selector for "the only visible consent content."

Attack 1.1: color:transparent

::first-line { color: transparent } makes only the first formatted line invisible. All text below the overflow clip boundary is also invisible (clipped), so the entire consent element renders as blank.

Attack 1.2: font-size:0

::first-line { font-size: 0 } collapses the first line to zero height. As each line collapses, the next line becomes the new "first line" — potentially cascading all text to zero height in some browser implementations.

Attack 1.3: letter-spacing:999px

::first-line { letter-spacing: 999px } pushes each glyph 999px apart. In a standard viewport, only the first character of the consent text is visible; all subsequent characters are off-screen to the right.

Attack 1.4: text-shadow mask

::first-line { color: transparent; text-shadow: 0 0 0 #fff } — transparent glyphs plus zero-offset background-colored text shadow fills each glyph position with the background color, camouflaging the text.

/* The ::first-line attack surface: consent in an overflow-clipped container */
.consent-panel {
  max-height: 2em;        /* Shows only one line */
  overflow: hidden;
  /* Visible: "By clicking Accept you agree to our data processing terms." */
  /* Hidden below clip: "Article 6(1)(a) GDPR lawful basis. Third-party sharing..." */
}

/* MCP attack: the one visible line becomes invisible */
.consent-panel::first-line {
  color: transparent;
  /* All text in the container is now invisible:
     - First line: transparent (::first-line attack)
     - Remaining lines: hidden by overflow:hidden
     Net result: blank white box where consent should be */
}

/* Detection: must use two-argument getComputedStyle */
const panel = document.querySelector('.consent-panel');
const cs = window.getComputedStyle(panel);              // element styles — attack invisible
const ps = window.getComputedStyle(panel, '::first-line'); // pseudo-element — reveals attack
console.log('element color:', cs.color);        // '#1a1a1a' — looks fine
console.log('::first-line color:', ps.color);   // 'rgba(0,0,0,0)' — reveals attack

The ::first-line detection requires checking the pseudo-element's color, font-size, letter-spacing, and text-shadow properties on every consent element. Stylesheet scanning for rules containing ::first-line is also necessary because computed values may be partially masked by inherited styles.

See the full ::first-line attack analysis for four attacks with complete detection code.

Part 2: ::first-letter Attacks — The Opening Capital Target

Consent disclosures typically begin with a capital letter: "By clicking Accept…", "You agree to…", "Article 6(1)(a)…". The ::first-letter pseudo-element matches precisely this opening character. Unlike ::first-line, it does not depend on overflow clipping — it always applies to the first letter of any block element, regardless of whether the element is height-constrained.

What makes ::first-letter dangerous is not what it hides — removing one character from a consent disclosure is, in isolation, minor. The danger is that ::first-letter also supports font-size and float, which are layout properties. Setting extreme values on these changes the entire layout of the consent element, not just the first character.

Attack 2.1: color:transparent

The opening capital becomes transparent. Most significant on short disclosures beginning with legal references: "Article 6(1)(a)" becomes "rticle 6(1)(a)" — the reference is broken.

Attack 2.2: font-size:100vw

The first character expands to viewport width. On a 1280px viewport, the "B" in "By clicking…" becomes 1280px wide. All following consent text wraps below this invisible character and is pushed beyond any height constraint.

Attack 2.3: float:left; width:100%

The drop-cap float occupies 100% of the container width. All inline content wraps below the float. In a height-constrained container, all consent body text is clipped below the floated character.

Attack 2.4: text-shadow mask

color:transparent; text-shadow: 0 0 0 var(--bg) fills the first character's glyph position with the background color. More robust than color-alone: also neutralizes any glyph outline or stroke.

/* Attack 2.2 in detail: viewport-width first letter */
.consent-body::first-letter {
  font-size: 100vw;
  /* On 1280px viewport:
     The letter "B" is 1280px wide and ~1280px tall (default line-height).
     The inline cursor position after "B" is at x=1280px.
     All following inline content ("y clicking Accept you agree to...") wraps to the
     next line — which starts at x=0, y=1280px.
     If the container has max-height: 80px → all consent body text is below 80px → clipped.

     The "B" itself is invisible if color:transparent is also set.
     Result: empty-looking 80px container; consent body all clipped below. */
  color: transparent; /* also hide the bloated character itself */
  line-height: 0;    /* optionally collapse the line box vertically */
}

/* Standard audit response:
   getComputedStyle(el).color → '#1a1a1a' ✓
   getComputedStyle(el).fontSize → '14px' ✓
   el.scrollHeight → 1280px (much taller than clientHeight)  ← detection signal

   Pseudo-element audit:
   getComputedStyle(el, '::first-letter').fontSize → '1280px' ← reveals attack
   getComputedStyle(el, '::first-letter').color → 'rgba(0,0,0,0)' ← reveals attack */

// Detection for font-size bloat:
function detectFirstLetterBloat(el) {
  const pseudoFs = parseFloat(window.getComputedStyle(el, '::first-letter').fontSize);
  const elFs = parseFloat(window.getComputedStyle(el).fontSize);
  if (pseudoFs > elFs * 4) {
    console.error('SECURITY: ::first-letter font-size bloat attack', { el, pseudoFs, elFs });
  }
  // Also check scroll height vs client height as a layout-effect signal
  if (el.scrollHeight > el.clientHeight * 3) {
    console.warn('SECURITY: consent element scrollHeight (' + el.scrollHeight + 'px) >> clientHeight (' + el.clientHeight + 'px) — possible layout attack:', { el });
  }
}

The float attack (2.3) is worth emphasizing because it exploits a legitimate CSS pattern (drop caps) to achieve a consent-hiding effect. A floated first letter at width: 100% looks syntactically identical to an over-sized drop cap to a human code reviewer. Only examining the combination of float: left and width: 100% together, in a consent-related context, reveals the attack intent.

See the full ::first-letter attack analysis for four attacks with complete detection code.

Part 3: ::placeholder Attacks — The Input Disclosure Target

Some MCP server consent implementations place the consent disclosure in the placeholder attribute of the sign-up email input rather than in a separate paragraph element. The pattern is used to save vertical space: "Enter your email" is replaced with a longer placeholder that includes the consent disclosure. This is already a GDPR Art. 7(2) violation (consent that disappears on interaction is not clearly presented), but MCP servers compound it by making the already-inadequate placeholder additionally invisible via ::placeholder CSS attacks.

The ::placeholder pseudo-element accepts color, font-size, text-overflow, opacity, and a subset of other properties. Each can be weaponized independently.

Attack 3.1: color:transparent

Placeholder text color becomes transparent. The input appears empty. The full consent text is in input.placeholder (DOM audit passes), but the rendered text is invisible to sighted users.

Attack 3.2: font-size:0

Placeholder text collapses to zero height. The input looks unlabeled. In some browser/AT combinations, a zero-font-size placeholder may also suppress the AT's reading of the placeholder text.

Attack 3.3: text-overflow:ellipsis

Long consent disclosure text is clipped to the input width plus "…". On a 300px input, 200+ characters of legal text becomes "By entering your email you conse…". Critical terms (data sharing, US transfer) are clipped off-screen.

Attack 3.4: opacity:0

Entire pseudo-element rendering suppressed. More comprehensive than color:transparent — also suppresses any background-color, border, or decoration applied to the placeholder. Nothing from the placeholder pseudo-element renders.

/* Attack 3.3 in detail: ellipsis clip of long consent placeholder */
input.consent-email-field::placeholder {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Input HTML:
   

   Rendered at input width 300px, font-size 14px:
   "By entering your email address yo…"
   (approximately 35-40 characters visible, 180+ characters clipped)

   The critical legal content — US data transfer, third-party sharing,
   AI training use — is in the clipped portion.

   DOM audit: input.placeholder → full 220-character text ✓
   Visual audit: user reads "By entering your email address yo…" and accepts. */

// Detection: Canvas-based measurement of placeholder visibility
function measurePlaceholderClipping(input) {
  const ph = input.getAttribute('placeholder') || '';
  const consentKeywords = ['consent', 'agree', 'terms', 'data', 'gdpr', 'privacy'];
  if (!consentKeywords.some(kw => ph.toLowerCase().includes(kw))) return;

  const pseudoStyle = window.getComputedStyle(input, '::placeholder');
  const textOverflow = pseudoStyle.textOverflow;
  const whiteSpace = pseudoStyle.whiteSpace;

  if (textOverflow === 'ellipsis' || whiteSpace === 'nowrap') {
    // Measure full text width vs input width
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    const styles = window.getComputedStyle(input);
    ctx.font = `${pseudoStyle.fontSize || styles.fontSize} ${styles.fontFamily}`;
    const fullWidth = ctx.measureText(ph).width;
    const inputWidth = input.clientWidth;

    if (fullWidth > inputWidth) {
      const fraction = inputWidth / fullWidth;
      const visibleChars = Math.floor(ph.length * fraction);
      const visibleText = ph.substring(0, visibleChars);
      const hiddenText = ph.substring(visibleChars);

      console.error('SECURITY: consent placeholder clipped by text-overflow:', {
        visibleText: visibleText + '...',
        hiddenText: hiddenText.substring(0, 100),
        inputWidth: inputWidth + 'px',
        fullTextWidth: Math.round(fullWidth) + 'px',
        visibleFraction: Math.round(fraction * 100) + '%'
      });
    }
  }
}

The ellipsis attack (3.3) is particularly dangerous because text-overflow: ellipsis is a legitimate and common UI pattern. A code reviewer who sees it applied to a placeholder will assume it is defensive defensive cropping of an overly long placeholder — not an attack. Only examining the full placeholder text alongside the rendered output reveals the consent suppression.

See the full ::placeholder attack analysis for four attacks with complete detection code.

Unified Pseudo-Element Consent Auditor

The following script provides a single entry-point for auditing all three pseudo-elements against consent elements on a page. It covers the 12 attacks described in this post and should be run as part of any MCP server security audit.

/**
 * Unified CSS pseudo-element consent auditor
 * Covers: ::first-line, ::first-letter, ::placeholder attacks
 * Usage: paste into browser DevTools console on the MCP server's consent page
 */
(function auditPseudoElementConsent() {
  const CONSENT_SELECTORS = [
    '#consent-panel', '.consent-body', '.consent-text', '.consent-disclosure',
    '[class*="consent"]', '[class*="disclosure"]', '[data-consent]',
    '[data-consent-required]'
  ];

  const CONSENT_KEYWORDS = [
    'consent', 'agree', 'terms', 'privacy', 'gdpr', 'data processing',
    'accept', 'by clicking', 'by entering', 'lawful basis', 'article 6'
  ];

  const findings = [];

  function hasConsentText(el) {
    const text = (el.textContent || el.getAttribute('placeholder') || '').toLowerCase();
    return CONSENT_KEYWORDS.some(kw => text.includes(kw));
  }

  function addFinding(severity, type, description, element) {
    findings.push({ severity, type, description, element });
    const fn = severity === 'HIGH' ? console.error : console.warn;
    fn(`[${severity}] ${type}: ${description}`, element);
  }

  // 1. Audit ::first-line
  const blockEls = document.querySelectorAll(CONSENT_SELECTORS.join(', '));
  blockEls.forEach(el => {
    if (!hasConsentText(el)) return;
    const ps = window.getComputedStyle(el, '::first-line');
    const es = window.getComputedStyle(el);

    const color = ps.color;
    const fontSize = parseFloat(ps.fontSize);
    const elFontSize = parseFloat(es.fontSize);
    const letterSpacing = parseFloat(ps.letterSpacing);

    if (color === 'rgba(0, 0, 0, 0)' || color === 'transparent') {
      addFinding('HIGH', '::first-line/color', 'First line color transparent', el);
    }
    if (fontSize < elFontSize * 0.1) {
      addFinding('HIGH', '::first-line/font-size', 'First line font-size collapsed (' + fontSize + 'px)', el);
    }
    if (letterSpacing > 50) {
      addFinding('HIGH', '::first-line/letter-spacing', 'First line letter-spacing bloat (' + letterSpacing + 'px)', el);
    }

    // text-shadow check via stylesheet scan
  });

  // 2. Audit ::first-letter
  blockEls.forEach(el => {
    if (!hasConsentText(el)) return;
    const ps = window.getComputedStyle(el, '::first-letter');
    const es = window.getComputedStyle(el);

    const color = ps.color;
    const fontSize = parseFloat(ps.fontSize);
    const elFontSize = parseFloat(es.fontSize);
    const float_ = ps.float;

    if (color === 'rgba(0, 0, 0, 0)' || color === 'transparent') {
      addFinding('MEDIUM', '::first-letter/color', 'Opening capital transparent', el);
    }
    if (fontSize > elFontSize * 4) {
      addFinding('HIGH', '::first-letter/font-size',
        'First letter bloated to ' + fontSize + 'px (element: ' + elFontSize + 'px)', el);
    }
    if (float_ !== 'none') {
      const width = parseFloat(ps.width);
      const containerWidth = parseFloat(es.width);
      if (width > containerWidth * 0.5) {
        addFinding('HIGH', '::first-letter/float',
          'Float width (' + width + 'px) > 50% of container (' + containerWidth + 'px)', el);
      }
    }
  });

  // 3. Audit ::placeholder
  document.querySelectorAll('input[placeholder], textarea[placeholder]').forEach(el => {
    const ph = el.getAttribute('placeholder') || '';
    if (!CONSENT_KEYWORDS.some(kw => ph.toLowerCase().includes(kw))) return;

    const ps = window.getComputedStyle(el, '::placeholder');

    const color = ps.color;
    const opacity = parseFloat(ps.opacity);
    const fontSize = parseFloat(ps.fontSize);
    const elFontSize = parseFloat(window.getComputedStyle(el).fontSize);
    const textOverflow = ps.textOverflow;

    if (color === 'rgba(0, 0, 0, 0)' || color === 'transparent') {
      addFinding('HIGH', '::placeholder/color', 'Placeholder consent text color transparent', el);
    }
    if (opacity < 0.1) {
      addFinding('HIGH', '::placeholder/opacity', 'Placeholder opacity ' + opacity, el);
    }
    if (fontSize < elFontSize * 0.1) {
      addFinding('HIGH', '::placeholder/font-size', 'Placeholder font-size collapsed (' + fontSize + 'px)', el);
    }
    if (textOverflow === 'ellipsis') {
      addFinding('HIGH', '::placeholder/text-overflow', 'Placeholder consent clipped by ellipsis', el);
    }
  });

  // 4. Stylesheet scan for all three pseudo-elements
  const PSEUDO_DANGER_PATTERNS = [
    { pseudo: '::first-line', prop: 'color', check: v => v === 'transparent' || v === 'rgba(0,0,0,0)' },
    { pseudo: '::first-line', prop: 'letter-spacing', check: v => parseFloat(v) > 50 },
    { pseudo: '::first-letter', prop: 'font-size', check: v => v.includes('vw') || v.includes('vh') },
    { pseudo: '::first-letter', prop: 'float', check: v => v !== 'none' },
    { pseudo: '::placeholder', prop: 'color', check: v => v === 'transparent' },
    { pseudo: '::placeholder', prop: 'opacity', check: v => parseFloat(v) < 0.1 },
  ];

  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        const sel = rule.selectorText || '';
        for (const pattern of PSEUDO_DANGER_PATTERNS) {
          if (sel.includes(pattern.pseudo)) {
            const val = rule.style[pattern.prop] || rule.style.getPropertyValue(pattern.prop);
            if (val && pattern.check(val)) {
              addFinding('HIGH', `stylesheet/${pattern.pseudo}`,
                `${pattern.prop}: ${val} in rule: ${sel}`, rule);
            }
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }

  // Summary
  console.log('\n=== PSEUDO-ELEMENT CONSENT AUDIT SUMMARY ===');
  console.log('Total findings:', findings.length);
  const highs = findings.filter(f => f.severity === 'HIGH').length;
  const mediums = findings.filter(f => f.severity === 'MEDIUM').length;
  console.log('HIGH severity:', highs);
  console.log('MEDIUM severity:', mediums);
  if (findings.length === 0) {
    console.log('No pseudo-element consent suppression attacks detected.');
  }
  return findings;
})();

GDPR and WCAG Compliance Analysis

Each pseudo-element attack maps to specific regulatory failures across GDPR and WCAG 2.1:

Attack GDPR failure WCAG failure
::first-line { color: transparent } Art. 7(2) — consent not clearly distinguishable; Art. 13(2) — data subject information not provided in a concise, intelligible, accessible form SC 1.4.3 Contrast (Minimum) — zero contrast for consent text
::first-letter { font-size: 100vw } Art. 7(2) — consent not presented in a clear and plain manner; the consent body is pushed below the visible area SC 1.3.3 Sensory Characteristics — consent not reachable by standard scroll; SC 2.1.1 Keyboard — content may be unreachable
::placeholder { text-overflow: ellipsis } Art. 7(2) — consent request incomplete; Art. 13/14 — required information not fully disclosed; consent in placeholder already violates Recital 32 SC 3.3.2 Labels or Instructions — placeholder as sole consent vehicle is non-compliant; SC 1.3.1 Info and Relationships
::placeholder { color: transparent } Art. 7(2) and Art. 13 — information not presented at all; consent mechanism is completely invisible SC 1.4.3 Contrast — zero contrast; SC 3.3.2 Labels or Instructions

The regulatory analysis makes clear that none of these attacks produce valid consent under GDPR. Article 7(1) places the burden of proof for consent on the controller — and consent obtained via an invisible, clipped, or layout-corrupted disclosure mechanism is not "freely given, specific, informed, and unambiguous" consent within the meaning of Article 4(11). Pseudo-element attacks that prevent the disclosure from being read are exactly the kind of "obscure clauses" that Recital 32 specifically prohibits.

Audit tools that miss pseudo-element attacks will report false compliance: Any consent management platform or GDPR scanner that does not implement pseudo-element-aware computed style checks will generate false-negative reports for MCP servers using these techniques. The DOM contains the full consent text. The element is visible. The computed styles look correct. Only the pseudo-element computed styles reveal the attack. Until audit tools add getComputedStyle(el, '::first-line'), getComputedStyle(el, '::first-letter'), and getComputedStyle(el, '::placeholder') to their check suites, these 12 attacks will continue to produce compliance certificates for non-compliant MCP servers.

What SkillAudit checks

SkillAudit's consent visibility scanner includes pseudo-element style inspection as a first-class audit step. For every consent element identified in an MCP server's client-facing UI, SkillAudit:

These checks are part of the Consent Visibility axis in SkillAudit's six-axis report card. An MCP server with any of the 12 attacks documented in this post will receive a failing grade on Consent Visibility and a detailed remediation report identifying the specific pseudo-element rule and the CSS property to remove or override.

See also:

← All posts

return findings; })();