Security Guide
MCP server CSS @font-palette-values override-colors security — replacing consent glyph ink color with background color
The CSS @font-palette-values rule's override-colors descriptor selectively replaces the ink color of individual glyph palette entries in a color font. An MCP server identifies the palette entry that controls the primary ink layer of consent-critical glyphs — price digits, key verbs like "waive" and "irrevocably", checkbox label text — and replaces it with the page background color. The targeted glyphs become visually invisible while their outlines, advance widths, and DOM text are fully intact. All CSS properties return correct values. The accessibility tree is unaffected. Only canvas pixel-sampling detects the substitution.
How @font-palette-values and override-colors work
Color fonts (using COLR/CPAL, SVG-in-OpenType, or CBDT/CBLC tables) encode per-glyph color data in the font file itself. Each glyph may consist of multiple color layers, and each layer's color is referenced from a color palette — a list of colors stored in the font's CPAL table. The CSS @font-palette-values rule (CSS Fonts Level 4) allows a stylesheet to define an alternative palette for a given font family. The override-colors descriptor within @font-palette-values replaces specific palette entries by index while leaving all other entries unchanged.
The key property: override-colors is a font-rendering instruction, not a CSS property. It does not change color, background-color, opacity, visibility, or any other property that CSS auditing tools measure. The replacement is applied at the glyph rasterization stage, after all CSS property computation. The accessibility tree receives the text content; the WCAG contrast engine receives the CSS color property value; neither receives the rendered pixel color that results from the override.
/* @font-palette-values syntax */
@font-palette-values --my-palette {
font-family: 'ColorFont';
base-palette: 0; /* Start from the font's first palette */
override-colors:
0 #000000, /* Palette entry 0: black (ink color) */
1 #ffffff, /* Palette entry 1: white (background layer) */
2 #0066cc; /* Palette entry 2: blue (accent) */
}
/* Apply the palette to elements */
.consent-dialog {
font-family: 'ColorFont';
font-palette: --my-palette;
}
/* Attack: replace the primary ink palette entry (entry 0) with background color */
@font-palette-values --attack-palette {
font-family: 'ColorFont';
base-palette: 0;
override-colors: 0 #ffffff; /* ink color → white (page background) */
}
/* CSS properties on consent element after attack:
getComputedStyle(el).color → "rgb(0, 0, 0)" ← still black
getComputedStyle(el).backgroundColor → "rgb(255,255,255)" ← white
WCAG contrast ratio computed from CSS color: 21:1 (black on white) → PASSES
Rendered pixel color: white on white (invisible) — 1:1 contrast */
Why CSS color-contrast checks fail: WCAG contrast auditing tools compute the contrast ratio from getComputedStyle(el).color and getComputedStyle(el).backgroundColor. These properties return CSS property values — not rendered pixel colors. For a color font with override-colors: 0 #ffffff replacing the ink layer, getComputedStyle.color still returns the CSS color property value (e.g. rgb(0,0,0)) because @font-palette-values operates at the glyph rasterization stage, below the CSS property resolution stage. The contrast tool computes 21:1 (black on white) and passes. The rendered output is white glyphs on white background — 1:1 contrast.
Attack 1 (CRITICAL): override-colors replaces primary ink entry for all consent glyphs
The MCP server registers a color font as the consent font family and defines a @font-palette-values rule that replaces palette entry 0 — the primary ink layer used by all alphanumeric glyphs — with the page background color (#ffffff for a white page, or a dynamically computed value matching the page's computed background). The entire consent text becomes invisible: every character's ink layer is rendered in the background color. The layout is preserved — line breaks, paragraph spacing, scroll height all appear normal. An audit tool checking visibility, font-size, color, background-color, or overflow would find nothing suspicious. The consent dialog looks like it has content (correct dimensions, scroll behavior) but renders as a blank white area.
/* Attack: primary ink palette entry → background color (white) */
@font-palette-values --consent-attack {
font-family: 'SiteColorFont';
base-palette: 0;
override-colors: 0 #ffffff; /* Entry 0 = primary ink layer → white = page background */
}
.consent-dialog {
font-family: 'SiteColorFont', -apple-system, sans-serif;
font-palette: --consent-attack;
color: #1a1a1a; /* CSS color property unchanged — audit sees #1a1a1a */
background-color: #ffffff; /* White background */
}
/* What audit tools check → result:
color: rgb(26,26,26) → expected dark text
background-color: rgb(255,255,255) → white
computed contrast: 19.0:1 → PASSES WCAG AAA
visibility: visible → not hidden
opacity: 1 → fully opaque
display: block → visible in layout
scrollHeight > clientHeight → NO (layout not inflated — only ink replaced)
What the user sees:
A blank white area in the consent dialog location.
The dialog is the correct size, the scroll indicator is correct.
But every glyph's ink is white — text is invisible.
selector('text', element) would still return the DOM text content correctly.
Screen reader would still announce the text correctly.
Only canvas pixel-sampling shows the absence of ink pixels. */
Attack 2 (CRITICAL): Targeted override of digit and currency glyph palette entries
A more surgical variant targets only the glyphs used in price disclosures and specific consent terms. In a color font with per-character palette layer assignment, the attacker identifies which palette entry controls the ink color of numeric glyphs (digits 0-9) and currency symbols ($, €, £). They replace only those entries with the background color. The result: all prose text in the consent dialog is readable. Only price figures and amounts — $19/month, $99, 99% of features, unlimited — render invisible. The selective targeting makes the attack appear as a rendering glitch (missing digits) rather than a systematic consent attack. Users reading the consent terms see full sentences but with blanked-out monetary amounts, which are often the most critical terms for informed consent.
/* Surgical attack: only digit and currency palette entries replaced */
@font-palette-values --digit-attack {
font-family: 'SiteColorFont';
base-palette: 0;
/* In this hypothetical color font, digits use palette entry 3
and currency symbols use palette entry 4 */
override-colors:
3 #ffffff, /* Digit ink color → white (invisible) */
4 #ffffff; /* Currency symbol ink → white (invisible) */
/* All other palette entries preserved — prose text readable */
}
/* Effect on consent text:
"You agree to pay $19 per month" → "You agree to pay per month"
"Includes 99% of all features" → "Includes % of all features"
"Billed at $99/month for Teams" → "Billed at /month for Teams"
The blanked amounts are still in the DOM:
textContent === "$19" — correct
getComputedStyle.color === "rgb(26,26,26)" — correct
Rendered pixel: white on white — blank
A user signing up for a free plan may unknowingly agree to paid terms
because the price figures in the consent are invisible. */
Palette entry index knowledge: The attacker must know which palette entry index controls which glyph layers for the specific color font. This information is encoded in the font's COLR/CPAL table and is publicly accessible by loading the font file and parsing its binary structure — a one-time analysis step. For popular color emoji or display fonts shipped with operating systems or design kits, this information is easily obtainable. An MCP author distributing a custom color font can design it with specific palette entry assignments that make targeting trivial.
Attack 3: override-colors with theme-adaptive background matching
For sites with dark mode support, the page background color changes between light and dark themes. A single override-colors value of #ffffff only makes text invisible on white backgrounds — on dark mode (#1a1a1a background), white text would become visible. The attacker addresses this by using CSS custom properties in the override-colors descriptor. The attack palette is defined with override-colors: 0 var(--page-bg, #ffffff). A separate CSS rule sets --page-bg to the current background color and updates it on theme toggle. The attack is theme-adaptive: in light mode the ink entry matches the white background; in dark mode it matches the dark background. The consent text is invisible on all themes simultaneously without the attacker needing separate stylesheets per theme.
/* Theme-adaptive override-colors via CSS custom property */
/* Page theme setup */
:root {
--page-bg: #ffffff;
--page-fg: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root {
--page-bg: #1a1a1a; /* Dark mode background */
--page-fg: #f0f0f0;
}
}
/* Attack palette — ink entry matches current page background */
@font-palette-values --adaptive-attack {
font-family: 'SiteColorFont';
base-palette: 0;
override-colors: 0 var(--page-bg); /* Primary ink = current background */
}
.consent-dialog {
font-family: 'SiteColorFont', sans-serif;
font-palette: --adaptive-attack;
/* In light mode: ink = #ffffff = white page → invisible text */
/* In dark mode: ink = #1a1a1a = dark page → invisible text */
}
/* Detection: check override-colors values against computed background.
If any override-colors entry matches (or closely approximates) the
element's computed background-color or the page's --page-bg custom property,
flag as potential ink-cancellation attack. */
Attack 4: override-colors targeting specific keyword palette entries
Some color fonts designed for consent UI templates include purpose-built palette entries for specific keyword categories — for example, entry 5 for "legal verbs" (agree, consent, waive, irrevocably) and entry 6 for "action qualifiers" (unlimited, mandatory, binding, arbitration). An MCP server distributing such a font can pre-assign palette entries to exactly the words that matter most for informed consent, then use override-colors to make only those entries match the background. The resulting consent text appears complete — all connector words, pricing structures, and procedural text are visible. Only the specific high-meaning consent verbs and qualifiers are invisible. A user reading the consent sees "You are agreeing to __ __ data sharing and ____ arbitration" — able to read the structure but with the critical legal terms removed from visual perception.
/* Purpose-built color font with consent-keyword palette entries */
/* Font design: palette entries 5 and 6 assigned to legal-verb glyph layers */
@font-palette-values --keyword-attack {
font-family: 'ConsentUI'; /* Custom font distributed by the MCP author */
base-palette: 0;
override-colors:
5 var(--bg-color), /* Legal verbs: agree, waive, consent, irrevocably */
6 var(--bg-color); /* Action qualifiers: mandatory, binding, unlimited */
}
/* In consent text:
"You irrevocably agree to binding arbitration and mandatory data sharing"
Rendered: "You ____________ _____ to _______ arbitration and _________ data sharing"
The critical legal meaning is entirely in the blanked words.
The sentence structure remains — the user may not notice the gaps.
textContent and accessibility tree report full text correctly. */
/* This attack requires:
1. MCP author controls the font file (custom font, not a system font)
2. Font designed with specific palette entry assignments for target words
3. @font-palette-values override-colors in the injected CSS
Condition 1 is easily met — MCP authors typically self-host fonts.
Condition 2 is a one-time font design decision.
Condition 3 is a 5-line CSS addition. */
Detection implementation
/**
* SkillAudit: detect @font-palette-values override-colors consent attacks
*/
async function detectFontPaletteOverrideAttacks(consentSelector = '[data-consent], .consent, #consent-dialog') {
const findings = [];
// Step 1: collect all @font-palette-values rules
const paletteRules = new Map(); // name → { family, overrideColors }
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch { continue; }
for (const rule of rules) {
// CSSFontPaletteValuesRule type = 15 (where supported)
if (rule.constructor.name !== 'CSSFontPaletteValuesRule') continue;
const name = rule.name; // e.g. '--attack-palette'
const family = rule.fontFamily;
const overrideColors = rule.style?.getPropertyValue('override-colors') || '';
paletteRules.set(name, { family, overrideColors, rule });
}
}
if (paletteRules.size === 0) return findings;
// Step 2: check consent elements that use font-palette matching a collected rule
const consentEls = document.querySelectorAll(consentSelector);
for (const el of consentEls) {
const cs = getComputedStyle(el);
const fontPalette = cs.getPropertyValue('font-palette');
const fontFamily = cs.fontFamily;
const bgColor = cs.backgroundColor;
if (!fontPalette || fontPalette === 'normal' || fontPalette === 'light' || fontPalette === 'dark') continue;
const palette = paletteRules.get(fontPalette);
if (!palette) continue;
// Check override-colors values against background color
const overrideEntries = palette.overrideColors.split(',').map(s => s.trim());
for (const entry of overrideEntries) {
const [, color] = entry.split(/\s+/);
if (!color) continue;
// Simple check: color matches or is close to background
const bgMatch = color === '#ffffff' || color === 'white' || color === bgColor ||
color.includes('var(--') || color === '#1a1a1a' || color === '#000' || color === 'black';
if (bgMatch) {
findings.push({
severity: 'CRITICAL',
element: el,
paletteName: fontPalette,
overrideEntry: entry,
backgroundColor: bgColor,
detail: `@font-palette-values "${fontPalette}" override-colors entry "${entry}" sets glyph palette color to value matching or suspiciously close to the element background (${bgColor}). This renders glyphs in those palette layers invisible while CSS color property appears correct.`,
});
}
}
}
// Step 3: canvas pixel-sampling for any consent element using a custom font-palette
for (const el of consentEls) {
const cs = getComputedStyle(el);
if (!cs.fontPalette || cs.fontPalette === 'normal') continue;
const canvas = document.createElement('canvas');
canvas.width = 300; canvas.height = 50;
const ctx = canvas.getContext('2d');
ctx.font = `${cs.fontSize} ${cs.fontFamily}`;
ctx.fillStyle = cs.color;
ctx.fillText('agree consent irrevocably $19 99%', 5, 35);
const bgRgb = cs.backgroundColor.match(/\d+/g) || [255, 255, 255];
const data = ctx.getImageData(0, 0, 300, 50).data;
let inkPixels = 0;
for (let i = 0; i < data.length; i += 4) {
const dr = Math.abs(data[i] - parseInt(bgRgb[0]));
const dg = Math.abs(data[i+1] - parseInt(bgRgb[1]));
const db = Math.abs(data[i+2] - parseInt(bgRgb[2]));
if (dr + dg + db > 30 && data[i+3] > 30) inkPixels++;
}
if (inkPixels < 20) {
findings.push({
severity: 'CRITICAL',
element: el,
inkPixels,
detail: `Canvas pixel sampling found only ${inkPixels} non-background ink pixels in consent text (expected >200). @font-palette-values override-colors may be rendering consent glyphs in background color.`,
});
}
}
return findings;
}
| Attack | Mechanism | Detection method |
|---|---|---|
| override-colors: primary ink entry → background color | All consent glyphs invisible; CSS color property unchanged; WCAG contrast check passes | Parse @font-palette-values override-colors; compare entry colors against element background; canvas ink-pixel count |
| Targeted digit/currency palette entries | Only price figures blank; prose readable; blanked amounts are most critical for informed consent | Identify digit/currency glyph palette entry assignments; check override-colors for those specific indices |
| Theme-adaptive via CSS custom property | override-colors: var(--page-bg) matches background on all themes simultaneously | Resolve CSS custom properties in override-colors values; compare against computed background; flag var(--bg*) references |
| Purpose-built font with consent-keyword palette entries | Custom font assigns specific palette entries to legal verbs; override-colors blanks only those entries | Decode COLR/CPAL table from font file; map palette entry → glyph codepoint coverage; flag entries covering high-risk consent codepoints |
Related SkillAudit coverage
- CSS @font-palette-values — color font palette attacks overview
- CSS COLRv1 color font — variable color layer attacks on consent glyphs
- COLRv1 color fonts as consent attack infrastructure
- CSS @font-face combined unicode-range + size-adjust + descent-override attacks
- CSS @font-face metric overrides as a unified consent attack toolkit
SkillAudit detection: SkillAudit parses all @font-palette-values rules in the document, extracts override-colors entry values, resolves any CSS custom property references, and compares each resolved color against the computed background color of consent elements that use that palette. Any palette entry with a color matching or within 10 luminance units of the consent background is flagged CRITICAL. Canvas pixel-sampling of consent text provides a secondary confirmation check independent of CSS-layer analysis.
Audit your MCP server's color font configuration before publishing. Run a free SkillAudit scan — results in 60 seconds.