MCP server CSS ::first-letter security: capital letter transparency, viewport-size bloat, float layout collapse, and text-shadow masking of the opening consent character
Published 2026-07-25 — SkillAudit Research
The CSS ::first-letter pseudo-element matches precisely the first letter (or letter-like punctuation character) of a block-level element, before any non-letter characters are skipped. It applies only to block containers and is subject to a restricted set of CSS properties — but that set includes color, font-size, float, text-shadow, background, opacity, letter-spacing, and line-height. Each of these can be weaponized independently.
The highest-value target for a ::first-letter attack is the opening capital letter of a consent paragraph: "By clicking Accept…", "You agree to…", "This service collects…". Corrupting just the first character of a short consent disclosure is enough to make it unreadable, while leaving the element's textContent completely intact and escaping all computed-style checks that only query the element's own properties (not its pseudo-element properties).
Detection blind spot: getComputedStyle(element) returns styles for the element itself. Pseudo-element styles require getComputedStyle(element, '::first-letter') with a second argument. Most automated consent audits query the element directly and never retrieve pseudo-element computed styles — creating a systematic detection gap that ::first-letter attacks exploit.
Attack 1: color:transparent makes the opening capital of consent text invisible
An MCP server applies color: transparent to ::first-letter on the consent paragraph. The first character of the consent disclosure — typically a capital letter — becomes invisible. The element's color property is unchanged (the paragraph may be #1a1a1a), and the element is not hidden. But the glyph representing the opening letter renders as a transparent shape: the character occupies its advance-width space, so no text reflow occurs, but the glyph fill color is transparent. In isolation this seems minor, but on consent disclosures that begin with legal shorthand — "Article 6(1)(a)", "GDPR requires", "Pursuant to" — removing the opening letter destroys readability of the legal reference. Combined with a narrow container that clips long lines, removing the first character can shift text wrapping in a way that hides a line break.
/* MCP attack — opening capital of consent paragraph invisible: */
.consent-body::first-letter,
#consent-text::first-letter,
[class*="disclosure"]::first-letter {
color: transparent;
/* The capital letter of "By clicking Accept you agree to..." disappears.
getComputedStyle(el).color → '#1a1a1a' (unchanged — this is the element color)
getComputedStyle(el, '::first-letter').color → 'rgba(0,0,0,0)' (the attack)
Only querying the pseudo-element reveals the attack. */
}
// Detection: must query ::first-letter computed style explicitly
function detectFirstLetterColorAttack() {
const consentEls = document.querySelectorAll(
'.consent-body, #consent-text, [class*="consent"], [class*="disclosure"], [data-consent]'
);
consentEls.forEach(el => {
const pseudoStyle = window.getComputedStyle(el, '::first-letter');
const pseudoColor = pseudoStyle.color;
if (pseudoColor === 'rgba(0, 0, 0, 0)' || pseudoColor === 'transparent') {
console.error('SECURITY: ::first-letter color is transparent on consent element:', {
el, selector: el.className || el.id, pseudoColor,
elementColor: window.getComputedStyle(el).color
});
}
// Also check for background-color matching the page background
const pseudoBg = pseudoStyle.backgroundColor;
const pageBg = window.getComputedStyle(document.body).backgroundColor;
if (pseudoBg !== 'rgba(0, 0, 0, 0)' && pseudoBg === pageBg) {
console.error('SECURITY: ::first-letter background matches page background (camouflage attack):', {
el, pseudoBg, pageBg
});
}
});
}
// Also scan stylesheets directly
function auditFirstLetterColorRules() {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSStyleRule) {
if (rule.selectorText && rule.selectorText.includes('::first-letter')) {
const color = rule.style.color;
const opacity = rule.style.opacity;
if (color === 'transparent' || opacity === '0') {
console.error('SECURITY: ::first-letter transparency rule:', {
selector: rule.selectorText, color, opacity
});
}
}
}
}
} catch (e) { /* cross-origin */ }
}
}
Attack 2: font-size:100vw bloats the first letter to viewport width, pushing all consent below fold
The ::first-letter pseudo-element supports font-size. Setting font-size: 100vw makes the first character of the consent paragraph as wide as the entire viewport. At font-size: 100vw on a 1280px-wide viewport, the letter "B" in "By clicking Accept" occupies a width of 1280px. The capital letter's advance-width then extends far beyond the right edge of the container, pushing the cursor position past the visible area. All subsequent text wraps below the bloated first letter and is pushed below the container's visible area if overflow: hidden or max-height is set. Even without overflow clipping, the user must scroll far below the header to see the actual consent terms — the "B" acts as an invisible viewport-filling spacer that displaces all following content.
/* MCP attack — first letter bloated to viewport width, consent body pushed below fold: */
.consent-wrapper::first-letter {
font-size: 100vw;
/* On 1280px viewport: 'B' in "By clicking Accept..." becomes 1280px wide.
line-height defaults proportional to font-size → line box is also 1280px tall.
All consent text wraps AFTER the 1280px×1280px invisible 'B' box.
If the consent wrapper has max-height: 80px and overflow: hidden,
the 1280px 'B' fills the entire visible area and all following text is clipped. */
}
/* Variant: combine with line-height collapse to prevent visible 'B' from showing: */
.consent-wrapper::first-letter {
font-size: 100vw;
line-height: 0; /* collapses line box to zero height */
/* Now: the 'B' occupies 0 vertical space but its advance-width pushes inline
cursor 1280px to the right. All following inline text wraps onto the next
visual line. In a max-height container, everything wraps below 0 height first
line, but then all subsequent lines are clipped by max-height. */
}
// Detection: check ::first-letter font-size for viewport-relative or large values
function detectFirstLetterFontSizeBloat() {
const consentEls = document.querySelectorAll(
'.consent-body, .consent-wrapper, #consent-text, [class*="consent"]'
);
consentEls.forEach(el => {
const pseudoStyle = window.getComputedStyle(el, '::first-letter');
const elementStyle = window.getComputedStyle(el);
const pseudoFontSize = parseFloat(pseudoStyle.fontSize);
const elementFontSize = parseFloat(elementStyle.fontSize);
// ::first-letter font-size should not be more than 3× the element's font-size
// Drop caps are legitimate but typically at most 3em (3× larger)
if (pseudoFontSize > elementFontSize * 4) {
console.error('SECURITY: ::first-letter font-size is', pseudoFontSize + 'px',
'vs element font-size', elementFontSize + 'px', '— viewport-fill bloat attack:', { el });
}
// Check for viewport-relative font-size in stylesheet rules
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSStyleRule &&
rule.selectorText && rule.selectorText.includes('::first-letter')) {
const rawSize = rule.style.fontSize;
if (rawSize && (rawSize.includes('vw') || rawSize.includes('vh') ||
rawSize.includes('vmin') || rawSize.includes('vmax') ||
parseFloat(rawSize) > 100)) {
console.error('SECURITY: ::first-letter uses viewport-relative or large font-size:', {
selector: rule.selectorText, fontSize: rawSize
});
}
}
}
} catch (e) { /* cross-origin */ }
}
});
}
Attack 3: float:left on ::first-letter collapses text flow around drop-cap, hiding consent body
The ::first-letter pseudo-element supports float. Setting float: left creates a drop-cap effect where the first letter floats left and surrounding inline content wraps to its right and below. MCP servers combine float: left; width: 100%; height: 100% (or float: left; margin-right: -100%) to make the floated first letter occupy the entire width of the consent container, pushing all inline content below the float. If the consent container has overflow: hidden or a fixed height, the wrapped content (the actual consent terms) is clipped. The float mechanism is visually identical to a legitimate oversized drop cap, making manual visual review insufficient.
/* MCP attack — ::first-letter float collapses consent text into hidden area: */
.consent-disclosure::first-letter {
float: left;
width: 100%;
/* The first letter floats left and occupies 100% of the container width.
All inline content (the rest of "By clicking Accept you agree to...")
wraps BELOW the float box.
If container has max-height: 2em + overflow:hidden → only the floating
first letter is visible; all consent body text is clipped below. */
font-size: 1rem; /* keeps the letter the same apparent size as body text */
/* The result looks like a normal paragraph to visual inspection
but the text layout is fundamentally broken by the float. */
}
/* Variant with negative margin — first letter covers all subsequent text: */
.consent-disclosure::first-letter {
float: left;
font-size: 2em;
height: 100vh; /* floated drop-cap extends to full viewport height */
margin-right: 0;
background: var(--bg); /* matches page background — invisible */
/* The 100vh-tall transparent float box occupies the left side of the container,
pushing consent body text to the right of the float then below it —
but the float itself is transparent, so the layout disruption is invisible
until the user inspects element positions. */
}
// Detection: check for suspicious float on ::first-letter
function detectFirstLetterFloat() {
const consentEls = document.querySelectorAll(
'.consent-body, .consent-disclosure, #consent-text, [class*="consent"]'
);
consentEls.forEach(el => {
const pseudoStyle = window.getComputedStyle(el, '::first-letter');
const elStyle = window.getComputedStyle(el);
const float_ = pseudoStyle.float;
if (float_ !== 'none') {
// Float on ::first-letter — check if it disrupts layout
const pseudoWidth = pseudoStyle.width;
const elWidth = parseFloat(elStyle.width);
const pseudoWidthPx = parseFloat(pseudoWidth);
// If floated ::first-letter is more than 50% of container width, suspicious
if (pseudoWidthPx > elWidth * 0.5) {
console.error('SECURITY: ::first-letter float occupies > 50% of container width:', {
el, float: float_, pseudoWidth, containerWidth: elWidth + 'px'
});
}
// Also check height — a drop-cap with height:100vh is never legitimate
const pseudoHeight = pseudoStyle.height;
if (pseudoHeight && parseFloat(pseudoHeight) > 100) {
console.error('SECURITY: ::first-letter float has large explicit height:', {
el, height: pseudoHeight
});
}
}
});
}
Attack 4: text-shadow with matching background color masks the first capital letter
The ::first-letter pseudo-element supports text-shadow. An MCP server sets color: transparent on ::first-letter (making the glyph fill transparent) and then applies a text-shadow with zero offset and the page background color. The shadow — which renders behind the glyph — fills the glyph's bounding box with the background color, completing the camouflage. The letter is physically present in the layout (no reflow or height change), but the glyph fill is transparent and the shadow behind it is indistinguishable from the background. This technique is more robust than simple color: transparent alone because it also neutralizes any glyph stroke or outline that might reveal the character's presence.
/* MCP attack — first letter camouflaged by transparent color + background-color shadow: */
.consent-text::first-letter {
color: transparent;
text-shadow: 0 0 0 #ffffff;
/* color:transparent → glyph fill is invisible
text-shadow: 0 offset, 0 blur, color=#ffffff (page background)
The shadow appears at the same position as the letter, fills the glyph
bounding box with white, completing the camouflage.
On dark-mode pages:
text-shadow: 0 0 0 #0a0a0a;
On pages using CSS variables:
text-shadow: 0 0 0 var(--bg); ← uses the same background variable */
}
// Detection: check for both color:transparent AND text-shadow on ::first-letter
function detectFirstLetterShadowMask() {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSStyleRule &&
rule.selectorText && rule.selectorText.includes('::first-letter')) {
const color = rule.style.color;
const textShadow = rule.style.textShadow;
const opacity = rule.style.opacity;
const isTransparent = color === 'transparent' ||
color === 'rgba(0, 0, 0, 0)' ||
opacity === '0';
if (isTransparent && textShadow && textShadow !== 'none') {
console.error('SECURITY: ::first-letter shadow-mask attack — transparent color + text-shadow:', {
selector: rule.selectorText, color, textShadow
});
}
// Even without explicit color, check for zero-offset opaque shadow
if (textShadow && textShadow.includes('0 0 0')) {
// Zero-offset shadow suggests camouflage intent
console.warn('SECURITY: ::first-letter has zero-offset text-shadow (potential shadow-mask):', {
selector: rule.selectorText, textShadow
});
}
}
}
} catch (e) { /* cross-origin */ }
}
}
::first-letter attacks escape standard auditing: The four attacks described here share a common property — they do not alter the DOM text content, they do not change getComputedStyle(element).color (only getComputedStyle(element, '::first-letter').color), and they do not trigger MutationObserver callbacks. Standard consent auditing pipelines that check visibility, ARIA attributes, and element-level computed styles will not detect any of these attacks. A complete audit must: (1) retrieve pseudo-element computed styles with the two-argument getComputedStyle form; (2) scan stylesheet rules for selectors containing ::first-letter; (3) measure the visual scroll height of consent elements and compare against container height to detect unexpected overflow; (4) verify that the element's first character is visually non-transparent using Canvas pixel sampling.
Attack summary
| Attack | CSS property | Effect on consent | Severity |
|---|---|---|---|
| Color transparency | ::first-letter { color: transparent } |
Opening capital glyph invisible; rest of text unaffected | Medium |
| Viewport-size font bloat | ::first-letter { font-size: 100vw } |
First letter occupies full viewport width; consent body below fold or clipped | High |
| Float layout collapse | ::first-letter { float: left; width: 100% } |
All consent body text forced below 100%-width float; clipped by overflow | High |
| Text-shadow background mask | ::first-letter { color: transparent; text-shadow: 0 0 0 var(--bg) } |
First letter camouflaged by zero-offset background-colored shadow | Medium |
Consolidated findings
font-size: 100vw (or similar viewport-relative value) on the ::first-letter pseudo-element of the consent paragraph. The first character occupies full viewport width, displacing all following inline content below the container's visible area. If the consent container has a fixed height or overflow: hidden, the entire consent disclosure is clipped. The attack requires no JavaScript and is invisible to computed-style audits that do not query pseudo-element styles.
float: left; width: 100% to ::first-letter, causing the floated first letter to fill the container's full width and push all inline content below the float. When combined with overflow clipping, all consent body text is hidden below the floated first letter. The visual result resembles a legitimate drop-cap layout to manual review.
color: transparent on ::first-letter, making the opening capital of the consent disclosure invisible. The effect is most significant on short consent disclosures where removing the first character destroys the legal reference (e.g. "Article 6(1)(a) GDPR" → "rticle 6(1)(a) GDPR"). Detection requires getComputedStyle(el, '::first-letter').color — standard audits that query element-level computed style miss this.
← Blog | CSS ::first-line attacks | CSS ::placeholder attacks | Security Checklist