MCP server CSS ::first-line pseudo-element security: color transparency, font-size collapse, letter-spacing overflow, and text-shadow masking attacks on visible consent text

Published 2026-07-25 — SkillAudit Research

The CSS ::first-line pseudo-element applies styles exclusively to the first formatted line of a block-level element — the line of text that appears first in visual rendering, determined dynamically based on the container's width and the text's line-breaking. It does not correspond to the first sentence or paragraph — it is the first rendered line as the browser lays out text. The ::first-line pseudo-element only accepts a restricted subset of CSS properties: font properties, color, background properties, text-decoration, text-transform, letter-spacing, word-spacing, line-height, and text-shadow.

MCP servers exploit ::first-line by combining it with a consent container that uses height + overflow: hidden to show only one or two lines. Because only the first line is visible, ::first-line rules target precisely the text the user can see — making it transparent, collapsing it, or masking it — while leaving the invisible clipped text below unaffected. This is a surgically precise attack: the pseudo-element's scope is exactly the attack surface.

Properties allowed on ::first-line: The spec restricts which properties apply to ::first-line. Allowed: font-* (font-size, font-family, font-weight, font-style, font-variant, font-stretch), color, background (background-color, background-image in some browsers), text-decoration, text-transform, letter-spacing, word-spacing, line-height, text-shadow, vertical-align, text-indent. NOT allowed: display, visibility, overflow, position, float, margin, padding. The allowed properties are exactly the ones needed to make text invisible or unreadable.

Attack 1: ::first-line color:transparent makes the only visible consent line invisible

A consent framework clips its disclosure to one visible line with height: 1lh (or approximately 22px) + overflow: hidden, showing only the opening sentence with a "read more" affordance. An MCP server injects ::first-line { color: transparent; } on the consent element. This makes exactly the one visible line invisible — the text is present in the DOM, el.textContent returns the full disclosure, but the only rendered line the user sees is transparent. The clipped content below is invisible due to overflow: hidden. The attack removes the only visible consent text with a single CSS rule that affects only the pseudo-element scope.

/* Consent framework shows one visible line: */
#consent-summary {
  height: 1lh;    /* approximately one line height */
  overflow: hidden;
  font-size: 14px;
  line-height: 1.6;
  /* Shows: "By using SkillAudit, you agree to our data processing terms..." */
}
#consent-summary::after {
  content: '...read more';
  /* "read more" affordance */
}

/* MCP attack — first-line transparency on consent element: */
#consent-summary::first-line,
.consent-intro::first-line,
[data-consent-brief]::first-line,
[class*="consent-lead"]::first-line {
  color: transparent;
  /* Effect: the one visible line becomes transparent.
     The background shows through.
     DOM text: "By using SkillAudit, you agree to our data processing terms..."
     Screen reader: reads the full text correctly (::first-line doesn't affect AT)
     Visual: blank — same background color shows through where text was.
     The "...read more" ::after is unaffected (it's not first-line content per se,
     though browser behavior on ::after content in ::first-line varies).
     User sees an empty gray box with "...read more" — no consent text visible. */
}

// Detection: getComputedStyle with ::first-line pseudo-element
function detectFirstLineTransparency() {
  const consentEls = document.querySelectorAll(
    '#consent-panel, .consent-wrapper, [class*="consent"], [data-consent]'
  );
  consentEls.forEach(el => {
    const firstLineCs = window.getComputedStyle(el, '::first-line');
    if (firstLineCs) {
      const color = firstLineCs.color;
      // Transparent or matching background = invisible first line
      const bgColor = window.getComputedStyle(el).backgroundColor;
      if (color === 'rgba(0, 0, 0, 0)' || color === 'transparent') {
        console.error('SECURITY: ::first-line color:transparent on consent element:', { el, color });
      }
      // Also check if first-line color matches background
      if (color === bgColor && color !== '' && color !== 'rgba(0, 0, 0, 0)') {
        console.warn('SECURITY: ::first-line color matches background on consent element:', {
          el, firstLineColor: color, backgroundColor: bgColor
        });
      }
    }
  });
}

Attack 2: ::first-line font-size:0 collapses visible line, making second line the new first-line

Setting font-size: 0 on ::first-line collapses the first rendered line to zero height — the text is there but occupies no space. The second line of the consent text now becomes the new first visible line (since the first line collapses to nothing). But ::first-line continues to apply font-size: 0 to what is now the new first rendered line — the second line of text. This cascades: each line becomes the "first line" as the previous one collapses, and all of them receive font-size: 0. The result is that the entire consent disclosure collapses to zero height — all text is invisible, the element has no rendered height. The consent container may visually disappear entirely (zero-height block with no borders).

/* MCP attack — font-size:0 on ::first-line collapses entire element cascade: */
.consent-disclosure::first-line,
#consent-text::first-line,
[role="dialog"] p::first-line {
  font-size: 0;
  /* How the cascade works:
     Initial text: "By using SkillAudit you agree... [line break]
                    to our data processing as described... [line break]
                    in our Privacy Policy. [line break]"
     After ::first-line font-size:0 on line 1:
       Line 1 collapses to 0px height → line 2 becomes the new first rendered line.
     After ::first-line font-size:0 on new line 1 (was line 2):
       Line 2 collapses → line 3 becomes the new first rendered line.
     This continues until all lines have collapsed.
     Result: zero-height element. All text invisible.

     Note: Browser behavior varies — some browsers apply ::first-line only once
     to the original first rendered line, not recursively. In those browsers,
     only the first line collapses. Even collapsing just the first visible line
     is a high-severity attack in a consent framework with height+overflow:hidden.
     The attack is worth attempting regardless of cascade behavior. */
}

// Detection: check ::first-line font-size
function detectFirstLineFontSizeCollapse() {
  const consentEls = document.querySelectorAll(
    '#consent-panel, .consent-wrapper, [class*="consent"], [data-consent]'
  );
  consentEls.forEach(el => {
    const flCs = window.getComputedStyle(el, '::first-line');
    if (flCs) {
      const fontSize = parseFloat(flCs.fontSize);
      if (fontSize === 0 || fontSize < 4) {
        console.error('SECURITY: ::first-line font-size:0 on consent element — visible line collapsed:', {
          el, firstLineFontSize: fontSize
        });
      }
    }

    // Also check actual element height vs expected height
    const normalFontSize = parseFloat(window.getComputedStyle(el).fontSize);
    if (el.getBoundingClientRect().height < normalFontSize * 0.5 && el.textContent.length > 20) {
      console.warn('SECURITY: consent element has suspiciously small height relative to text content:', {
        el, height: el.getBoundingClientRect().height, fontSize: normalFontSize,
        textLength: el.textContent.length
      });
    }
  });
}

Attack 3: ::first-line letter-spacing:999px pushes all glyphs in the visible line off-screen

The letter-spacing property is allowed on ::first-line. Setting letter-spacing: 999px on the first line inserts 999px of extra space between each character. In a consent disclosure opening with "By using SkillAudit", the first character "B" renders at the left edge of the container; "y" renders at 999px + character width ≈ 1010px to the right — far outside the container's viewport. All subsequent characters are pushed further off-screen. The first line is effectively blank: only the "B" (or first character) is potentially visible, while the rest of the line's glyphs are 1000+px off-screen. If the container has overflow: hidden, those off-screen characters are invisible. The attack leaves the DOM text intact and el.textContent fully readable, but the visual rendering shows only the first character (or nothing, if the container is narrow enough).

/* MCP attack — extreme letter-spacing on ::first-line: */
#consent-panel p::first-line,
.consent-disclosure::first-line,
[class*="gdpr-text"]::first-line {
  letter-spacing: 999px;
  /* Characters in first line:
     "B" at x=0 (normal position)
     "y" at x ≈ 0 + charWidth + 999px ≈ 1006px (off-screen in any viewport)
     " " at x ≈ 1006 + spaceWidth + 999px ≈ 2011px
     "u" at x ≈ 2011 + charWidth + 999px ≈ 3018px
     ... all beyond any viewport width.
     Container overflow:hidden clips at container boundary.
     Visible: "B" (first character only, if container is > 8px wide)
     or nothing if consent text wraps to letter-spacing pushes "B" off too.
     User sees: blank or just the first character "B" — no readable consent text. */
}

// Detection: check letter-spacing on ::first-line pseudo-element
function detectFirstLineLetterSpacingOverflow() {
  const consentEls = document.querySelectorAll(
    '#consent-panel, .consent-wrapper, [class*="consent"], [data-consent]'
  );
  consentEls.forEach(el => {
    const flCs = window.getComputedStyle(el, '::first-line');
    if (flCs) {
      const letterSpacing = parseFloat(flCs.letterSpacing);
      if (letterSpacing > 20) {  // letter-spacing > 20px is never legitimate for body text
        console.error('SECURITY: extreme letter-spacing on ::first-line of consent element:', {
          el, letterSpacing, unit: 'px'
        });
      }
    }
  });
}

Attack 4: ::first-line text-shadow opaque overlay masks consent text without removing it from DOM

The text-shadow property is permitted on ::first-line. An MCP server sets text-shadow: 0 0 0 rgba(255,255,255,1) (white, zero blur, zero offset) combined with color: transparent. The transparent text color hides the actual glyphs; the white text-shadow with zero offset and zero blur renders an opaque white rectangle in the same position as each glyph — effectively painting white rectangles that fill the character bounding boxes. On a white background consent panel, this is indistinguishable from blank space. For dark-background consent panels, the MCP sets the text-shadow color to match the panel's background color (var(--bg-alt) or the computed background value), achieving the same masking effect. Unlike background-color on the pseudo-element (which some browsers don't support for ::first-line), text-shadow is universally supported.

/* MCP attack — ::first-line text-shadow opaque mask: */

/* Variant A: White background panel */
.consent-panel.light-theme::first-line {
  color: transparent;              /* hide actual glyph outlines */
  text-shadow: 0 0 0 #ffffff;     /* white zero-blur shadow covers glyph positions */
  /* Effect: white rectangles replace each character's glyph area.
     On a white background, this is indistinguishable from blank space.
     The text is fully present in DOM; only the visual layer is masked. */
}

/* Variant B: Dark background panel (consent framework dark mode) */
.consent-panel.dark-theme::first-line {
  color: transparent;
  text-shadow: 0 0 0 #1a1a2e;    /* match panel background color */
  /* Dynamic variant using CSS custom properties: */
}

/* Variant C: Dynamic background matching via custom property */
:root {
  --consent-bg: #f8f9fa;  /* MCP reads this from consent framework's CSS vars */
}
[data-consent-panel]::first-line {
  color: transparent;
  text-shadow: 0 0 0 var(--consent-bg);  /* use framework's own bg var to match */
  /* Even if consent framework changes its background color, this adapts automatically. */
}

// Detection: check ::first-line for text-shadow combined with transparent color
function detectFirstLineTextShadowMask() {
  const consentEls = document.querySelectorAll(
    '#consent-panel, .consent-wrapper, [class*="consent"], [data-consent]'
  );
  consentEls.forEach(el => {
    const flCs = window.getComputedStyle(el, '::first-line');
    if (!flCs) return;

    const color = flCs.color;
    const textShadow = flCs.textShadow;

    // Flag: transparent text color + any text-shadow = masking attack
    const isTransparent = color === 'rgba(0, 0, 0, 0)' || color === 'transparent';
    const hasShadow = textShadow && textShadow !== 'none';

    if (isTransparent && hasShadow) {
      console.error('SECURITY: ::first-line text-shadow mask attack — color:transparent with text-shadow covering glyphs:', {
        el, color, textShadow
      });
    }

    // Also flag: text-shadow with zero offset (same position as glyphs)
    if (hasShadow) {
      // Parse shadow values: h-offset v-offset blur color
      const shadowValues = textShadow.split(',').map(s => s.trim());
      shadowValues.forEach(shadow => {
        // Rough parse: check if h-offset and v-offset are both near 0
        const parts = shadow.replace(/\s+/g, ' ').split(' ');
        const hOffset = parseFloat(parts[0]);
        const vOffset = parseFloat(parts[1]);
        if (Math.abs(hOffset) < 2 && Math.abs(vOffset) < 2) {
          console.warn('SECURITY: ::first-line text-shadow with near-zero offset may mask glyphs:', {
            el, textShadow: shadow
          });
        }
      });
    }
  });
}

Why ::first-line is a precision attack vector: Most CSS text-hiding attacks target an entire consent element — hiding everything. ::first-line targets precisely the text visible in a height-clipped consent widget. When a consent framework shows "1 line of consent text + read more", the ::first-line attack hides exactly that 1 line — while leaving all the clipped text unaffected (it's already invisible due to overflow:hidden). The attack surface is a single CSS rule, affecting a single pseudo-element scope, targeting the single most important piece of visual information in the consent flow. Detection must check getComputedStyle(el, '::first-line') for color, font-size, letter-spacing, and text-shadow on all consent elements.

Attack summary

Attack CSS rule Effect Severity
Transparent color on first visible line ::first-line { color: transparent; } Only visible consent line becomes invisible High
font-size:0 collapses first-line cascade ::first-line { font-size: 0; } First line collapses; may cascade to all lines High
Extreme letter-spacing off-screen push ::first-line { letter-spacing: 999px; } All glyphs after first character pushed off-screen High
text-shadow opaque glyph mask ::first-line { color:transparent; text-shadow:0 0 0 #fff; } Opaque background-colored shadow covers glyphs High

Consolidated finding blocks

High CSS ::first-line color:transparent — visible consent line made invisible: MCP server applies ::first-line { color: transparent; } to the consent element. When the consent container shows only one visible line (height-clipped with overflow:hidden), this rule targets exactly the visible text, making it transparent against the background. DOM text content, accessibility tree text, and all other computed styles are unaffected. Detection: getComputedStyle(el, '::first-line').color should not be transparent on consent elements.
High CSS ::first-line font-size:0 — visible consent line collapsed to zero height: MCP server applies ::first-line { font-size: 0; } to the consent element. The first rendered line collapses to zero height, making its text invisible and potentially cascading to subsequent lines in browsers that re-evaluate the "first line" after collapse. The consent element may visually disappear entirely. Detection: getComputedStyle(el, '::first-line').fontSize should not be zero on consent elements.
High CSS ::first-line letter-spacing:999px — all glyphs after first pushed off-screen: MCP server applies extreme letter-spacing via ::first-line. The first character renders at its normal position; all subsequent characters are spaced 999px apart, placing them far beyond any viewport. With overflow: hidden on the consent container, these off-screen characters are invisible. The user sees at most the first character of the consent disclosure. Detection: getComputedStyle(el, '::first-line').letterSpacing above 20px is suspicious.
High CSS ::first-line text-shadow mask — opaque shadow covers glyph positions while color:transparent hides outlines: MCP server combines color: transparent with a zero-offset text-shadow colored to match the consent panel background. The transparent color removes glyph outlines; the opaque text-shadow renders filled rectangles in the same positions — creating a perfectly camouflaged mask. Works in all browsers that support text-shadow on ::first-line. Detection: check for color: transparent + any text-shadow combination on ::first-line.

← Blog  |  CSS ::highlight attacks  |  Security Checklist