MCP server CSS font-weight thin rendering consent security
CSS font-weight:100 (Thin) selects the lightest available weight axis of a typeface. At small font sizes, Thin weight produces stroke widths approaching sub-pixel scale: glyph strokes that are mathematically present but physically invisible at 96 dpi display resolution. Combined with near-transparent color or loaded via a custom @font-face with empty glyph substitution, font-weight attacks represent a more visually deceptive category than simple zero-value attacks. Four SA-CSS-FWT patterns document the font rendering attack surface.
Font-weight attacks operate at the glyph rendering layer rather than the CSS property layer. Unlike opacity:0 or color:transparent, which zero out pixel values explicitly, thin-weight attacks reduce the physical stroke width of glyphs until they fall below the display resolution threshold. The text may be theoretically renderable — the glyph outline exists in the font file — but the stroke width in CSS pixels is less than one device pixel, so no pixel is set during rasterization.
The custom font attack takes this further: an @font-face declaration loads a web font that replaces the normal glyphs for consent-relevant characters with empty outlines. The DOM text content is the correct consent string. The font rendering engine produces zero-area paths for each character. The result is visually identical to opacity:0 but resistant to any CSS property audit, since all CSS properties show normal values.
SA-CSS-FWT-001: font-weight:100 + font-size:8px — ultra-thin sub-pixel strokes
OpenType Thin weight (100) typically has a stem width between 3–5% of the em unit. At font-size:8px, 5% of the em unit is 0.4px. The stroke width is 0.4 CSS pixels at 96 dpi — well below the 1px device pixel threshold. Browsers that perform sub-pixel antialiasing may produce fractional-opacity pixels, but even with antialiasing the total stroke coverage is less than 1 pixel wide. For all practical purposes, the text is invisible.
/* Attack: Thin weight at small size — sub-pixel stroke width */
.consent-text {
font-weight: 100; /* Thin: lightest weight axis (if available in font) */
font-size: 8px; /* 8px em: 5% stem = 0.4px stroke width */
color: #111; /* color appears normal: not transparent */
/* Computed properties all appear valid:
fontWeight: '100' (not 0)
fontSize: '8px' (not 0)
color: 'rgb(17,17,17)' (not transparent)
→ All individual checks pass.
Only compound check (weight × size) reveals sub-pixel strokes. */
}
/* Why WCAG misses this:
WCAG 1.4.3 contrast: #111 on #fff → ~17:1 → PASSES 4.5:1 requirement
WCAG 1.4.4 resize text: user can zoom → text theoretically visible at 200%
No WCAG criterion checks computed stroke width in pixels.
Result: WCAG A/AA pass, text invisible at 1×. */
/* Detection:
const fw = parseInt(getComputedStyle(el).fontWeight);
const fs = parseFloat(getComputedStyle(el).fontSize);
// Estimate stroke width: typical Thin = 4% of em
const estimatedStrokePx = fs * 0.04;
if (fw <= 100 && estimatedStrokePx < 1) → CRITICAL
// |stroke < 1px| = sub-pixel at any normal display
if (fw <= 200 && fs < 10) → HIGH (thin + small = illegible)
if (fw <= 300 && fs < 8) → HIGH (light + very small) */
All individual CSS property checks pass: fontWeight is 100 (not 0), fontSize is 8px (not 0), color is a real color value (not transparent). WCAG contrast passes. The attack is invisible only to auditors that evaluate the combination of weight and size in terms of physical stroke width. Compute estimatedStrokePx = fontSize * 0.04 and flag when below 1.
font-weight: 100 + font-size: 8px: estimated stroke width 0.32px; sub-pixel; invisible at 96dpi; all individual CSS checks pass. Detection: estimated strokePx = fontSize × 0.04 < 1 AND fontWeight ≤ 100 → CRITICAL.SA-CSS-FWT-002: font-weight:100 + color:rgba(0,0,0,0.1) — compound thin + near-transparent
Combining ultra-thin weight with low color opacity creates a compound attack where two individually-detectable values are each set to levels that might pass individual checks: rgba(0,0,0,0.1) is not transparent (alpha is 0.1, not 0) and font-weight:100 is a valid weight. But the visual output is: very thin strokes at 10% opacity. Even if sub-pixel antialiasing produces fractional pixel values, a 0.4px stroke at 10% opacity contributes at most 0.04 coverage units per pixel — effectively invisible. This compound pattern is specifically designed to evade auditors that check weight and color independently with thresholds like opacity < 0.1 or fontWeight < 100.
/* Attack: thin weight + near-transparent color compound */
.consent-text {
font-weight: 100;
color: rgba(0, 0, 0, 0.1); /* 10% black */
font-size: 12px; /* slightly larger than attack 1 */
background: #ffffff;
/* Computed stroke: 12px × 4% = 0.48px × 0.1 opacity = 0.048px equivalent
WCAG contrast: rgba(0,0,0,0.1) on white = 1.16:1 → FAILS 4.5:1!
→ Actually detectable by WCAG contrast check!
But: attacker may set background to match low contrast:
background: rgba(0,0,0,0.08) on white = very faint gray background
→ computed contrast: 1.04:1 → still fails, but may not be checked */
}
/* Variant: near-zero alpha that passes naive threshold checks */
.consent-text {
font-weight: 100;
color: rgba(0, 0, 0, 0.15); /* 15% — may pass threshold of < 0.1 */
font-size: 11px;
/* WCAG contrast: rgba(0,0,0,0.15) on white ≈ 1.22:1 → still fails WCAG
But if auditor uses threshold: opacity > 0.1 passes...
Combined with thin weight: still invisible */
}
/* Detection: compound threshold */
const alpha = /* parse alpha from getComputedStyle(el).color */ ;
const fw = parseInt(getComputedStyle(el).fontWeight);
const fs = parseFloat(getComputedStyle(el).fontSize);
// Each property independently:
if (alpha < 0.2) → flag color transparency
if (fw <= 100 && fs < 12) → flag thin+small
// Compound: thin weight AND low alpha
if (fw <= 200 && alpha < 0.3) → CRITICAL (compound thin + semi-transparent)
WCAG contrast catches color-transparency attacks but not weight attacks: rgba(0,0,0,0.1) on white produces 1.16:1 contrast, which fails the 4.5:1 requirement. A color-only check would catch this. But the compound pattern is designed for cases where color is slightly above thresholds; the weight multiplier makes it invisible even when color alone might pass a lenient check. Always check weight as a multiplier on effective stroke visibility.
font-weight:100 + color:rgba(0,0,0,0.1): compound; thin strokes at low opacity; effective coverage near zero. Detection: fontWeight ≤ 200 AND color alpha < 0.3 → CRITICAL.SA-CSS-FWT-003: @font-face empty glyph substitution — zero-path characters
A custom web font loaded via @font-face can define glyph outlines for any Unicode code point. An attacker can create a font where every consent-relevant character maps to an empty glyph: a code point entry with a zero-area path or a zero-advance width. When this font is applied to consent text, the browser renders each character as an empty shape — zero pixels in the glyph bitmap. The DOM text content ("By clicking Allow, you consent to...") is correct. textContent, accessibility APIs, and screen readers all read the correct string. The font file is the attack vector, and it is nearly invisible to CSS property audits, which examine computed style values rather than font file contents.
/* Attack: @font-face font with empty glyphs for consent characters */
@font-face {
font-family: 'ConsentFont';
src: url('https://malicious-cdn.example.com/emptyfont.woff2');
/* This font file maps every printable ASCII character
to an empty glyph path: {}
All characters: zero advance width, zero path.
Rendered: no pixels. */
}
.consent-text {
font-family: 'ConsentFont', serif;
/* 'ConsentFont' is loaded; all consent chars render empty.
CSS properties: font-family: 'ConsentFont' — appears valid.
fontWeight, fontSize, color: all normal values.
getComputedStyle: nothing suspicious.
textContent: full consent string.
Visual output: nothing. */
}
/* Why this defeats all CSS property audits:
getComputedStyle(el).fontFamily → 'ConsentFont' (loaded, resolves)
getComputedStyle(el).fontSize → '16px' (normal)
getComputedStyle(el).color → 'rgb(0,0,0)' (black)
getComputedStyle(el).opacity → '1' (not transparent)
All checks: PASS. No CSS property reveals empty glyphs.
Detection requires font analysis:
1. Check loaded fonts: document.fonts.check('16px ConsentFont')
2. Inspect font source URL for external CDN hostnames (suspicion signal)
3. Canvas rendering: draw text to canvas, check if any non-white pixels exist
4. Compare: render 'A' with consent font to canvas; if 0 non-background pixels → CRITICAL
Canvas detection approach:
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = '16px ConsentFont';
ctx.fillStyle = 'black';
ctx.fillText('A', 0, 20);
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const hasInk = Array.from(pixels).some((v, i) => i % 4 === 3 && v > 10);
if (!hasInk) → CRITICAL (font renders no pixels for 'A') */
CSS property audits cannot detect empty-glyph fonts: All computed style values are normal. The attack requires cross-origin font inspection or canvas rendering verification. Flag any @font-face declaration whose source is an external CDN that is not a recognized font provider. Use the Canvas API to verify that the loaded font renders non-zero pixels for a test character.
@font-face with empty glyph substitution: all CSS properties normal; all consent characters render as zero-area paths. Detection: Canvas render test — if test character produces zero ink pixels with loaded font → CRITICAL.SA-CSS-FWT-004: variable font weight animation — weight collapses at click moment
CSS variable fonts support animating the font-weight axis via @keyframes. An attack uses a variable font with a weight axis from 100 (Thin) to 900 (Black): the consent text is shown at weight 400 (Regular) for the first several seconds — readable during the intended reading window — then the animation transitions to weight 1 at the exact moment the user is expected to click. The final-keyframe collapse produces a Thin weight that is visually invisible. The animation uses step-end timing and fill-mode:forwards, identical to the opacity animation attack (SA-CSS-ANO-001) but targeting weight rather than opacity. Static CSS analysis at document load sees font-weight:400; only getAnimations() inspection reveals the collapse endpoint.
/* Attack: variable font weight animation — readable then invisible at click */
@keyframes weight-collapse {
0% { font-weight: 400; } /* Regular: readable during display period */
99% { font-weight: 400; } /* Still Regular: stays readable */
100% { font-weight: 1; } /* Instant collapse to weight 1 (near-Thin) */
}
.consent-text {
font-family: 'InterVariable', sans-serif; /* variable font with wght axis */
animation: weight-collapse 10s step-end 1 forwards;
/* step-end: holds at 400 until final frame, then instant jump to 1.
fill-mode:forwards: holds at weight:1 after animation completes.
At t=0-9.9s: font-weight:400 — text readable.
At t=10s: instant jump to font-weight:1 — text effectively invisible.
Static audit at t=0: getComputedStyle.fontWeight → '400' (normal).
Only animation inspection reveals the collapse at t=10s. */
}
/* Detection: same approach as SA-CSS-ANO (animation opacity attacks) */
const animations = el.getAnimations();
for (const anim of animations) {
if (anim.effect?.getKeyframes) {
const frames = anim.effect.getKeyframes();
const lastFrame = frames[frames.length - 1];
if (lastFrame.fontWeight !== undefined) {
const endWeight = parseInt(lastFrame.fontWeight);
if (endWeight <= 100) → CRITICAL (SA-CSS-FWT-004: weight collapses at end)
}
}
}
// Also check: does font support wght axis?
// document.fonts.forEach(f => { if (f.family === 'InterVariable') checkVariableAxes(f); });
Variable font weight animations share the same detection approach as opacity animations: getAnimations() on the consent element returns all running and pending animations. For each animation, inspect the final keyframe for fontWeight values. A final-keyframe weight below 100 on a variable font is a consent attack. The static style at document load time does not reveal this.
@keyframes weight-collapse to font-weight:1 at animation end with fill-mode:forwards: readable during display period; invisible at click moment. Detection: getAnimations() final keyframe fontWeight ≤ 100 → CRITICAL.Summary of SA-CSS-FWT findings
| ID | Severity | Attack vector | Rendering effect | Detection |
|---|---|---|---|---|
| SA-CSS-FWT-001 | CRITICAL | font-weight:100 + font-size:8px | Stroke width 0.32px — sub-pixel, invisible at 96dpi | estimatedStrokePx = fontSize × 0.04 < 1 AND fontWeight ≤ 100 |
| SA-CSS-FWT-002 | CRITICAL | font-weight:100 + color:rgba(0,0,0,0.1) | Thin strokes at 10% opacity: near-zero coverage | fontWeight ≤ 200 AND color alpha < 0.3 → CRITICAL |
| SA-CSS-FWT-003 | CRITICAL | @font-face empty glyph substitution | All glyphs: zero-area paths; no pixels; CSS properties normal | Canvas render test: test char produces zero ink pixels → CRITICAL |
| SA-CSS-FWT-004 | CRITICAL | Variable font @keyframes weight to 1 at end | Readable at start; collapses to weight:1 at click moment | getAnimations() final keyframe fontWeight ≤ 100 → CRITICAL |