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;
}
AttackMechanismDetection method
override-colors: primary ink entry → background colorAll consent glyphs invisible; CSS color property unchanged; WCAG contrast check passesParse @font-palette-values override-colors; compare entry colors against element background; canvas ink-pixel count
Targeted digit/currency palette entriesOnly price figures blank; prose readable; blanked amounts are most critical for informed consentIdentify digit/currency glyph palette entry assignments; check override-colors for those specific indices
Theme-adaptive via CSS custom propertyoverride-colors: var(--page-bg) matches background on all themes simultaneouslyResolve CSS custom properties in override-colors values; compare against computed background; flag var(--bg*) references
Purpose-built font with consent-keyword palette entriesCustom font assigns specific palette entries to legal verbs; override-colors blanks only those entriesDecode COLR/CPAL table from font file; map palette entry → glyph codepoint coverage; flag entries covering high-risk consent codepoints

Related SkillAudit coverage

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.