Security Guide
MCP server CSS font-synthesis-weight security — disabling bold synthesis to hide consent text
The CSS font-synthesis-weight property prevents the browser from algorithmically synthesizing a bold variant of a font. When set to none on a consent element, the browser must use an explicit @font-face weight:700 source instead of generating bold glyphs. An MCP server exploits this by injecting a @font-face rule whose bold weight maps to a font file with blank or zero-width glyphs — making every bolded word inside the consent dialog invisible while regular-weight text remains fully readable.
How font-synthesis-weight works
The font-synthesis-weight property is a longhand of the font-synthesis shorthand, introduced in CSS Fonts Level 4. It accepts two values: auto (the default — browser may synthesize bold) and none (synthesis forbidden — the browser must use an explicitly loaded weight variant or render the text at the closest available weight without artificial bolding).
When font-synthesis-weight: auto, a browser encountering font-weight: bold on an element whose font family has no bold variant will apply an algorithmic stroke-widening transformation to the available glyphs to simulate boldness. This is purely a rendering effect — no new font file is downloaded. When font-synthesis-weight: none, this synthesis is suppressed. The browser instead performs the CSS font matching algorithm (CSS Fonts Level 4, §4.3) and selects the closest weight variant from the loaded @font-face set. If an attacker has injected a @font-face for weight:700 pointing to a malicious font, that font is loaded and used.
/* Longhand syntax */ font-synthesis-weight: auto; /* default — browser synthesizes bold if needed */ font-synthesis-weight: none; /* synthesis disabled — must use loaded weight variant */ /* Part of font-synthesis shorthand */ font-synthesis: weight style small-caps; /* all three enabled */ font-synthesis: none; /* all three disabled */ /* font-synthesis-weight is distinct from font-synthesis-style (italic) and font-synthesis-small-caps (small-caps synthesis) */
Why this matters for consent dialogs: Consent dialogs commonly bold the most legally significant terms: "I agree", "binding arbitration", "data sharing", "opt-out". These bolded phrases are frequently the only text in the consent that creates legal obligation. Making bold text invisible while leaving regular-weight text readable creates a consent dialog that appears complete but strips its most critical clauses.
Attack 1 (CRITICAL): font-synthesis-weight:none + injected blank bold variant
The core attack combines two steps. First, the MCP server sets font-synthesis-weight: none on the consent container. Second, it injects a @font-face rule for the same font family at weight:700 that sources a font file with blank glyphs — either an actual font file served from an attacker-controlled domain, or an inline data: URI containing a minimal font with empty glyph outlines. When any element inside the consent dialog uses font-weight: bold or font-weight: 700, the browser loads the blank font instead of synthesizing bold from the regular variant.
/* Step 1: MCP injects a blank bold variant via @font-face */
@font-face {
font-family: 'ConsentFont';
font-weight: 700;
font-style: normal;
/* Blank glyph font served from attacker CDN or inline data: URI */
src: url('https://cdn.attacker.example/blank-bold.woff2') format('woff2');
}
/* Step 2: MCP sets font-synthesis-weight:none on the consent container */
.consent-dialog {
font-family: 'ConsentFont', Arial, sans-serif;
font-synthesis-weight: none;
/* Synthesis disabled — browser MUST use @font-face weight:700 source.
Any child with font-weight:bold uses the blank-bold.woff2 font.
*/
}
/* The consent HTML has bold critical terms */
/*
→ "binding arbitration agreement" and "right to a jury trial" render as blank space.
*/
Attack 2 (HIGH): Inline data: URI blank font — no external network request
To avoid network-detectable requests to attacker infrastructure, the blank bold font can be encoded as a data: URI directly in the injected stylesheet. A minimal WOFF2 font with all glyphs having empty contours can be as small as ~400 bytes when base64-encoded. This eliminates any network indicator that would reveal the attack: no suspicious CDN request, no cross-origin font load, no CSP connect-src violation. The malicious font is entirely self-contained in the injected CSS rule.
/* Self-contained blank bold font via data: URI */
@font-face {
font-family: 'ConsentFont';
font-weight: 700;
src: url('data:font/woff2;base64,d09GMgABAAAAAAIsAA...') format('woff2');
/* Base64 encodes a minimal WOFF2 with blank glyph outlines for A-Z, a-z, 0-9,
common punctuation. Renders all characters as zero-width invisible glyphs.
No network request — entirely in-memory. CSP font-src self does NOT block this
because data: URIs are not 'self'.
*/
}
/* CSP bypass note:
font-src 'self' blocks external font URLs but allows data: URIs by default unless
the CSP also includes a specific data: exclusion for font-src.
Most production CSPs do not restrict data: fonts.
*/
CSP blind spot: A Content Security Policy with font-src 'self' blocks cross-origin font file requests, blocking Attack 1. However, data: URI fonts bypass font-src 'self' unless the policy explicitly lists data: in the font-src directive. Attack 2 is immune to the most common font CSP configurations.
Attack 3: Selective weight targeting — only the critical weight is blank
A sophisticated variant targets only the specific weight value used for the most critical consent text, leaving other weights readable. If the consent dialog uses font-weight: 700 for section headings and font-weight: 600 for inline emphasis, the attacker supplies blank glyphs only for weight:700. The section headings disappear; the inline emphasis at weight:600 remains visible (the browser synthesizes it from the regular weight:400 variant, which is allowed because synthesis is only disabled for weight:700 when the attacker specifically targets it). Users see most of the consent dialog intact, reducing suspicion while the most critical headings ("WHAT YOU ARE AGREEING TO", "YOUR RIGHTS") are blank.
/* Surgical targeting: blank only the critical weight */
@font-face {
font-family: 'ConsentFont';
font-weight: 700; /* Only weight:700 is blank */
src: url('data:font/woff2;base64,...BLANK...') format('woff2');
}
/* weight:600 is NOT overridden — browser synthesizes it from weight:400 (readable) */
/* weight:400 is NOT overridden — regular text (readable) */
.consent-dialog {
font-synthesis-weight: none; /* Applied globally — synthesis disabled for all weights */
}
/* Effect:
font-weight:400 → regular readable text ✓
font-weight:600 → synthesized from weight:400 (because no 600 @font-face) ...
WAIT — font-synthesis-weight:none means synthesis is OFF.
So weight:600 falls through to nearest available weight (400).
Text renders at weight:400 (readable but not bolded). ✓
font-weight:700 → uses injected blank @font-face. Text is INVISIBLE. ✗
*/
Attack 4: font-synthesis-weight on ::first-line or ::first-letter pseudo-elements
CSS pseudo-elements including ::first-line and ::first-letter support font-synthesis-weight. An MCP server that cannot inject styles on the consent container directly may inject via a pseudo-element rule. The first line of each consent paragraph — which commonly contains the most important opening clause — uses the blank bold font while subsequent lines render normally. The visual gap is subtle: the paragraph begins with a blank line, then readable text continues. Users may interpret the first line as a heading or decorative element and skip it, reading only the continuation text.
/* Pseudo-element targeting — first line of each consent paragraph */
.consent-text::first-line {
font-synthesis-weight: none;
font-weight: 700;
/* Combined with the injected @font-face weight:700 blank font,
the first line of every consent paragraph renders as blank space.
*/
}
/* The ::first-line pseudo-element applies to whatever text falls on the first
rendered line — its extent changes with viewport width and font size.
The attacker doesn't need to know exactly what text is on line 1;
the opening clause of the consent (always line 1) disappears.
*/
Detection implementation
/**
* SkillAudit: detect font-synthesis-weight consent attacks
*/
function detectFontSynthesisWeightAttacks(consentSelector = '[data-consent], .consent, #consent-dialog') {
const findings = [];
// Check for font-synthesis-weight:none in stylesheets
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch { continue; }
for (const rule of rules) {
if (rule.type === CSSRule.STYLE_RULE) {
const fsw = rule.style.getPropertyValue('font-synthesis-weight');
const fs = rule.style.getPropertyValue('font-synthesis');
if (fsw === 'none' || fs === 'none' || (fs && fs.includes('weight') && !fs.includes('auto'))) {
findings.push({
severity: 'HIGH',
selector: rule.selectorText,
property: 'font-synthesis-weight',
value: fsw || fs,
detail: `Selector "${rule.selectorText}" disables font-synthesis-weight. If a @font-face weight:700 with blank glyphs exists for the same family, bold text in this selector renders invisible.`,
});
}
}
// Check @font-face rules for weight:700 with data: URI sources
if (rule.type === CSSRule.FONT_FACE_RULE) {
const src = rule.style.getPropertyValue('src') || '';
const weight = rule.style.getPropertyValue('font-weight') || '';
if (src.includes('data:') && (weight === '700' || weight === 'bold')) {
findings.push({
severity: 'CRITICAL',
type: '@font-face',
family: rule.style.getPropertyValue('font-family'),
weight,
detail: `@font-face weight:${weight} uses a data: URI source. Combined with font-synthesis-weight:none, this may supply blank glyphs for bold text in consent elements. Decode and inspect the font.`,
});
}
}
}
}
// Walk consent elements and check computed font-synthesis-weight
const consentEls = document.querySelectorAll(consentSelector);
for (const el of consentEls) {
const cs = getComputedStyle(el);
const fsw = cs.getPropertyValue('font-synthesis-weight');
if (fsw === 'none') {
// Also check for bold children
const boldChildren = el.querySelectorAll('strong, b, [style*="font-weight:bold"], [style*="font-weight: bold"], [style*="font-weight:700"]');
if (boldChildren.length > 0) {
findings.push({
severity: 'HIGH',
element: el,
detail: `Consent element has font-synthesis-weight:none with ${boldChildren.length} bold child element(s). Bold text relies on @font-face weight:700 variant — verify that variant is not serving blank glyphs.`,
});
}
}
}
return findings;
}
| Attack | Mechanism | Detection method |
|---|---|---|
| none + injected blank @font-face weight:700 | Synthesis disabled; blank font used for bold | Detect font-synthesis-weight:none + @font-face weight:700 with suspicious source |
| Inline data: URI blank font | No network request; bypasses font-src CSP | Flag @font-face with data: URI at bold weight; decode and inspect glyph outlines |
| Surgical single-weight targeting | Only critical weight is blank; others readable | Enumerate all @font-face weight variants; check each for blank glyphs |
| ::first-line pseudo-element targeting | First line of each paragraph invisible | Check pseudo-element rules for font-synthesis-weight:none + font-weight:bold |
Related SkillAudit coverage
- CSS font-synthesis shorthand — disabling all synthesis types to hide consent text
- CSS font-synthesis-small-caps — small-caps synthesis disabled for consent attack
- CSS @font-face injection attacks replacing consent fonts entirely
- CSS @font-face descent-override — metric manipulation collapsing consent line boxes
- CSS @font-face size-adjust — shrinking consent glyphs to zero rendering size
SkillAudit detection: SkillAudit scans all stylesheet rules for font-synthesis-weight: none on selectors that intersect consent elements. For each match, it cross-references loaded @font-face rules at bold weights in the same font family, decodes any data: URI font sources, and checks glyph metrics for zero-advance-width or empty outlines. Any bold-weight @font-face with suspicious glyph metrics on a consent font family is flagged CRITICAL.
Audit your MCP server's font synthesis configuration before publishing. Run a free SkillAudit scan — results in 60 seconds.