MCP server CSS font-weight extreme values security
CSS Fonts Level 4 expanded font-weight from seven keyword values to the continuous numeric range 1–1000. The standard keywords map to multiples of 100 (thin=100, normal=400, bold=700). Values below 100 — specifically the minimum of 1 — are only valid with variable fonts that support the wght axis. At font-weight: 1, a variable font renders consent text as sub-pixel hairline strokes functionally invisible at typical display resolutions (96–227 DPI). textContent is intact, getComputedStyle(el).color reports the expected dark text color, and geometric checks return normal dimensions. The only reliable detection signal is parsing getComputedStyle(el).fontWeight as an integer and flagging values below 100.
Attack findings
Background: CSS Fonts Level 4 numeric font-weight range
Prior to CSS Fonts Level 4 (widely supported from 2018 onward), font-weight accepted only seven keywords: normal (400), bold (700), bolder, lighter, and the multiples-of-100 numerics 100–900. CSS Fonts Level 4 extended the numeric range to any integer from 1 to 1000, with sub-100 values valid for variable fonts that expose the wght OpenType axis at those extremes. The Google Fonts variable font library, Inter, and many other popular variable fonts support wght axis minimums of 100 or even 1. When a font's wght axis accepts 1, the browser renders at the thinnest possible weight — sometimes described as "hairline" — with stroke widths that on a 96 DPI display can be less than 0.5 physical pixels after anti-aliasing, effectively disappearing against a white background.
Detection gap: Detectors that check getComputedStyle(el).fontWeight for only the keyword values ('normal', 'bold', 'thin') will miss numeric 1. Detectors that compare fontWeight against a string list of known-bad values ('100', '200') will miss '1'. The correct check parses the resolved value as an integer: parseInt(getComputedStyle(el).fontWeight) and flags values below 100. Additionally, font-weight: 1 via a CSS custom property does not appear in the element's inline style — the resolved value is only available via getComputedStyle.
Attack 1 — direct font-weight:1 with variable font (SA-CSS-FWT-001)
The MCP server loads a variable font via @font-face with a wght axis ranging from 1 to 900 (or uses a Google Fonts variable font CDN URL that includes this range). The consent element receives font-weight: 1. At weight 1 with a 14px font size on a standard 96 DPI display, the rendered stroke width is typically 0.14–0.28 physical pixels before anti-aliasing. After sub-pixel anti-aliasing, the text appears as a barely-perceptible light grey haze against a white background — not definitively invisible, but below the threshold for comfortable reading. On higher-DPI displays (200+ DPI as on modern phones and retina laptops), the effective stroke width is even smaller relative to the display's pixel grid. A user in dim lighting or with modest visual acuity sees no text.
/* Attack: variable font at weight 1 */
@font-face {
font-family: 'AppFont';
src: url('https://fonts.gstatic.com/s/inter/v19/variable.woff2') format('woff2-variations');
font-weight: 1 900; /* declares support for weight axis 1–900 */
}
.consent-text {
font-family: 'AppFont', sans-serif;
font-weight: 1; /* hairline — sub-pixel strokes */
font-size: 14px; /* passes size check */
color: #1a1a1a; /* passes color check */
opacity: 1; /* passes opacity check */
visibility: visible; /* passes visibility check */
}
SA-CSS-FWT-001 (High). Detection: const fw = parseInt(getComputedStyle(consentEl).fontWeight); if (fw < 100) flag('SA-CSS-FWT-001', fw); — standard keyword values are never below 100; a value of 1–99 is only reachable via explicit numeric declaration on a variable font, which is suspicious on consent text.
/* Detection */
function checkExtremeWeight(consentEl) {
const fw = parseInt(getComputedStyle(consentEl).fontWeight, 10);
if (isNaN(fw)) return null;
if (fw < 100) {
return {
vuln: 'SA-CSS-FWT-001',
detail: `font-weight resolved to ${fw} — sub-pixel strokes likely on variable font`
};
}
return null;
}
Attack 2 — CSS custom property weight resolution (SA-CSS-FWT-002)
The consent element declares font-weight: var(--ui-weight). At the :root level, --ui-weight: 400 — a legitimate default. A scoped CSS rule targets the install dialog container with --ui-weight: 1. The consent element, being a descendant of the dialog, inherits this value and resolves font-weight to 1. A scanner that checks the consent element's inline style attribute finds no font-weight declaration. A scanner that checks the element's rule-matched stylesheets finds a font-weight: var(--ui-weight) declaration which looks normal — the var() reference must be resolved to discover the effective value. Only getComputedStyle(consentEl).fontWeight reveals the resolved integer '1'.
/* Attack: weight via CSS custom property */
:root { --ui-weight: 400; }
.install-dialog { --ui-weight: 1; } /* scoped override — looks like a design token */
.consent-text { font-weight: var(--ui-weight); }
/* Static rule scan: sees "font-weight: var(--ui-weight)" — appears normal */
/* getComputedStyle: fontWeight = "1" — reveals the attack */
/* Detection: always use getComputedStyle, never read declared rules */
function checkWeightViaCustomProperty(consentEl) {
const fw = parseInt(getComputedStyle(consentEl).fontWeight, 10);
if (fw < 100) {
const declaredStyle = consentEl.style.fontWeight;
const isViaVar = declaredStyle === '' || declaredStyle.startsWith('var(');
return {
vuln: 'SA-CSS-FWT-002',
detail: `font-weight resolved to ${fw} via CSS custom property — declared: "${declaredStyle || 'none'}"`
};
}
return null;
}
Attack 3 — compound weight + font-size sub-threshold attack (SA-CSS-FWT-003)
A single-axis attack (weight 1 at normal size, or tiny size at normal weight) might trip one detector while passing the other. The compound attack sets both font-weight: 1 and font-size: 9px. At 9px with weight 1, the effective stroke width is approximately 0.1 physical pixels on a 96 DPI display. The text is completely invisible in practice. A size-only detector flags the 9px font-size and reports the issue. But if a scanner checks weight OR size (separately, not combined), each check independently might use different thresholds — a "weight below 200" threshold passes weight 1 as flagged, and a "size below 10px" threshold catches the 9px. The attack is defeated by a scanner that correctly checks both axes. The compound version is relevant because it can use a slightly less extreme weight (e.g., font-weight: 50) that some detectors might allow, paired with a slightly sub-threshold font-size (e.g., 11px), where neither alone crosses the flagging threshold but the combination makes text illegible.
/* Attack: compound sub-threshold — weight 50 + size 11px */
.consent-text {
font-weight: 50; /* above some detectors' "below 100" threshold */
font-size: 11px; /* above some detectors' "below 10px" threshold */
/* effective stroke: ~0.2px — invisible at normal viewing distance */
}
/* Detection: compound legibility score */
function checkCompoundWeightSize(consentEl) {
const cs = getComputedStyle(consentEl);
const fw = parseInt(cs.fontWeight, 10);
const fs = parseFloat(cs.fontSize);
const legibilityScore = (fw / 100) * (fs / 14);
if (legibilityScore < 0.5 && fw < 200) {
return {
vuln: 'SA-CSS-FWT-003',
detail: `compound attack: font-weight:${fw} font-size:${fs}px legibility score:${legibilityScore.toFixed(2)}`
};
}
return null;
}
Attack 4 — mousedown CSS transition weight collapse (SA-CSS-FWT-004)
At page load the consent element has font-weight: 400 and a CSS transition declared on font-weight: transition: font-weight 300ms ease. This declaration is present in the page stylesheet and looks like a standard UI animation directive — harmless in isolation. At mousedown on the install button, JS changes the consent element's font-weight to 1. Over 300ms, the weight smoothly transitions from 400 to 1. Because variable font weight axes are interpolatable, the browser renders intermediate weights (400 → 300 → 200 → 100 → 50 → 1) as the user's finger or cursor descends. The consent text fades from fully readable to invisible over the duration of a normal click gesture. When the click event fires, the weight is at or near 1 and the consent is effectively invisible. A static audit at page load observes font-weight: 400 — within the acceptable range. The transition is declared but targeting a neutral property value at load time.
/* Attack: CSS transition weight collapse at mousedown */
/* In stylesheet (visible at page load audit): */
.consent-text {
font-weight: 400;
transition: font-weight 300ms ease; /* looks like normal animation */
}
/* At mousedown: */
installBtn.addEventListener('mousedown', () => {
consentEl.style.fontWeight = '1';
/* weight transitions: 400→1 over 300ms — text invisible by click event */
});
/* Detection: MutationObserver + transition check */
new MutationObserver(() => {
const cs = getComputedStyle(consentEl);
const fw = parseInt(cs.fontWeight, 10);
if (fw < 100) {
flagTampering('SA-CSS-FWT-004');
installBtn.disabled = true;
}
}).observe(consentEl, { attributes: true, attributeFilter: ['style'] });
/* Also check: was font-weight listed in transition-property? */
function checkWeightTransition(consentEl) {
const cs = getComputedStyle(consentEl);
const tp = cs.transitionProperty;
if (tp.includes('font-weight') || tp === 'all') {
return { vuln: 'SA-CSS-FWT-004', detail: 'font-weight in transition-property — weight can be animated to 1 at mousedown' };
}
return null;
}
SkillAudit detection: SkillAudit parses getComputedStyle(el).fontWeight as an integer and flags values below 100 on consent elements. It also checks for font-weight in transition-property (signaling potential mousedown animation) and monitors weight changes during simulated install click via MutationObserver and requestAnimationFrame polling. Run a free audit →
Detection summary
| Attack ID | Properties involved | Key detection signal |
|---|---|---|
| SA-CSS-FWT-001 | font-weight:1 on consent + variable font with wght axis min≤1 loaded via @font-face | parseInt(getComputedStyle(el).fontWeight) < 100 |
| SA-CSS-FWT-002 | font-weight:var(--ui-weight) + scoped --ui-weight:1 on ancestor; declared style looks normal | getComputedStyle.fontWeight resolved to sub-100 integer despite no explicit inline weight |
| SA-CSS-FWT-003 | font-weight:50 + font-size:11px compound sub-threshold; each axis below perception threshold | compound legibility score: (fontWeight/100) × (fontSize/14) < 0.5 with fontWeight < 200 |
| SA-CSS-FWT-004 | CSS transition on font-weight + JS mousedown sets weight to 1; 300ms fade from visible to invisible | font-weight in transitionProperty AND/OR MutationObserver catches fontWeight < 100 during click simulation |