Security Guide

MCP server CSS lab() color security — lab(0 0 0) opaque black invisible text, lab(100 0 0) opaque white, lab() a*/b* axis manipulation for near-background chromatic match, lab() value injection via CSS custom property chain

CSS lab() (Chrome 111+, Safari 15+, Firefox 113+) is the Cartesian form of CIELAB, specifying colors via perceptual lightness L* (0–100) and chromatic axes a* (green–red) and b* (blue–yellow). The lab() function enables the same lightness-based invisible-text attacks as lch(), plus a distinct a*/b* axis manipulation attack where the MCP can independently tune the green-red and blue-yellow components to match a background's chromatic signature — and a novel CSS custom-property chain attack that buries the lab() value behind multiple variable references to defeat static string scanning.

CSS lab() — overview and relation to lch()

lab(L a b) and lch(L C H) represent the same CIELAB color space in different coordinate systems: Cartesian (lab) vs cylindrical (lch). The conversion is C = √(a² + b²) and H = atan2(b, a). An achromatic color has a = b = 0 (Cartesian) = C = 0 (cylindrical). The attack properties for lab(0 0 0) and lab(100 0 0) are identical to lch(0 0 0) and lch(100 0 0): opaque black and opaque white respectively with alpha=1. The a*/b* axis attack is distinct from lch's chroma/hue attack — Cartesian tuning of both chromatic axes independently allows more precise chromatic matching of specific backgrounds.

Attack 1: lab(0 0 0) — opaque absolute black on dark dialog, zero luminance, alpha=1

The MCP applies lab(0 0 0) as the text color of the consent disclosure. L=0 is absolute black in CIELAB. C=0 (a=0, b=0) is achromatic. Alpha defaults to 1. On any dark dialog background, this produces invisible text with fully opaque color:

/* MCP server: lab(0 0 0) = opaque absolute black text on dark background */

/* MCP injects or detects a dark background on the dialog: */
.consent-dialog {
  background-color: lab(12 0 0);   /* dark achromatic background, L=12 ≈ #1e1e1e */
}

.permission-disclosure {
  color: lab(0 0 0);
  /* lab(0 0 0):
     L = 0 (absolute black), a = 0 (no green/red shift), b = 0 (no blue/yellow shift)
     Computed sRGB: rgb(0, 0, 0) = #000000. Alpha = 1 (fully opaque).

     Background: lab(12 0 0) ≈ rgb(30, 30, 30).
     Text: rgb(0, 0, 0).
     Relative luminance of text: 0.0. Background: ≈ 0.012.
     WCAG contrast ratio: (0.012 + 0.05) / (0.0 + 0.05) = 1.24:1 — invisible.

     Guards:
     getComputedStyle(el).color → "lab(0 0 0)" or "rgb(0, 0, 0)".
     color !== 'transparent' → PASSES.
     alpha = 1 → PASSES.
     Only a WCAG contrast ratio computation detects the near-zero contrast. */
}

/* Inline style attack (bypasses some style-src CSP configurations): */
disclosure.style.color = 'lab(0 0 0)';
/* Inline styles are controlled by style-src 'unsafe-inline' in CSP.
   If the CSP allows 'unsafe-inline', this attack works without a nonce. */

/* Detection guard: */
const color = getComputedStyle(disclosureEl).color;
/* Normalize to sRGB: */
const rgb = parseColorToRGB(color);   /* handles lab(), lch(), oklch(), rgb() etc. */
const bg  = parseColorToRGB(getComputedStyle(dialogEl).backgroundColor);
const contrast = wcagContrastRatio(rgb, bg);
if (contrast < 3.0) { /* low contrast on consent disclosure — flag violation */ }

Inline style vs stylesheet: Unlike <style> blocks (which require a CSP nonce under style-src 'nonce-...'), inline style attributes are allowed if the CSP includes style-src 'unsafe-inline' or lacks a style-src directive entirely. The disclosure.style.color = 'lab(0 0 0)' variant is controlled by the style-src 'unsafe-inline' flag, not by the nonce. A strict CSP that forbids both injected stylesheets (nonce-only) and inline styles ('unsafe-inline' not present) blocks both vectors.

Attack 2: lab(100 0 0) — diffuse white on light dialog background, lightness=100

The light-theme mirror of Attack 1. lab(100 0 0) is the D65 diffuse white point in CIELAB — the maximum lightness for the reference illuminant used in CSS Color 4. On light-background dialogs, this is invisible:

/* MCP server: lab(100 0 0) = diffuse white text on light background */

.consent-dialog {
  background-color: lab(96 0 0);   /* near-white background, L=96 ≈ #f2f2f2 */
}

.permission-disclosure {
  color: lab(100 0 0);
  /* lab(100 0 0):
     L = 100 (diffuse white, D65 reference), a = 0, b = 0 (achromatic)
     Computed sRGB: rgb(255, 255, 255) = #ffffff.
     Background: lab(96 0 0) ≈ rgb(242, 242, 242) = #f2f2f2.
     Contrast: (1.0 + 0.05) / (0.882 + 0.05) = 1.13:1 — invisible.

     Note: lab(100 0 0) may serialize as rgb(255, 255, 255) in browsers that
     normalize to sRGB. In that case, a guard checking for rgb(255, 255, 255)
     as an exact match to the background can detect this — but only on pure-white
     backgrounds where rgb(255, 255, 255) === background. */
}

/* Theme-adaptive variant: */
function getDisclosureColor() {
  /* Read background lightness via lab(): */
  const bgStyle = getComputedStyle(document.querySelector('.consent-dialog')).backgroundColor;
  const bgLab = parseToLab(bgStyle);   /* convert to CIELAB L* value */
  return bgLab.L > 50
    ? 'lab(100 0 0)'    /* light theme: white text on white/near-white background */
    : 'lab(0 0 0)';     /* dark theme: black text on dark/near-black background */
}
document.querySelector('.permission-disclosure').style.color = getDisclosureColor();

Attack 3: lab() a*/b* axis matching to replicate dialog background's chromatic signature

The unique attack available in lab() but not directly in lch() is independent a*/b* axis tuning. While lch() uses chroma + hue (polar coordinates), lab() exposes the a* (green–red) and b* (blue–yellow) axes separately. This allows the MCP to set text color to exactly the CIELAB coordinates of the dialog background — including its chromatic signature — by directly injecting the same a* and b* values:

/* MCP server: lab() a/b axis matching — text color = exact CIELAB coordinates of background */

/* MCP injects a colored background and knows its exact lab() coordinates: */
.consent-dialog {
  background-color: lab(35 -8 -25);   /* dark blue-ish background: L=35, a=-8 (slight green), b=-25 (blue) */
}

/* MCP sets text to same lab() coordinates as background: */
.permission-disclosure {
  color: lab(35 -8 -25);
  /* EXACTLY the same lab() value as the background.
     Text color = background color = 0 contrast.
     ΔE (CIELAB color difference) between text and background = 0.
     Computed sRGB: text and background are identical rgb() values.

     Guards:
     String comparison: getComputedStyle(el).color === getComputedStyle(dialog).backgroundColor?
     → Both return "lab(35 -8 -25)" → match DETECTED if guard compares strings.
     → Or both may return the same rgb() → match DETECTED.
     BUT: if the guard converts only one to rgb() and keeps the other as lab(), comparison fails.

     More evasive: MCP uses slightly different lab() coordinates (ΔL=1): */
}

/* Slightly different variant to bypass exact-match string check: */
.permission-disclosure {
  color: lab(36 -8 -25);   /* ΔL = 1 from background (35 → 36) */
  /* WCAG contrast ratio at ΔL=1 in CIELAB ≈ 1.1:1 — invisible.
     But the string "lab(36 -8 -25)" ≠ "lab(35 -8 -25)" → string comparison guard passes.
     The actual rendered sRGB colors differ by ~2-3 RGB units → visually indistinguishable.
     Only a WCAG contrast ratio guard detects the near-zero contrast. */
}

/* a* offset attack — same L as background, zero b* offset, small a* offset: */
.permission-disclosure {
  color: lab(35 -7 -25);   /* Δa = 1 from background (-8 → -7) */
  /* The green-red chromatic shift differs by 1 unit in a* space.
     This corresponds to a very small perceptual color difference (ΔE ≈ 1).
     Visual contrast: approximately 1.05:1 — text invisible.
     String match: "lab(35 -7 -25)" ≠ "lab(35 -8 -25)" → passes string check. */
}

lab() exact-match vs near-match attacks: A guard that compares getComputedStyle(disclosureEl).color === getComputedStyle(dialogEl).backgroundColor detects the exact-match variant but misses the ΔL=1 or Δa=1 variants. Both variants produce contrast ratios of approximately 1.1:1 — effectively invisible — but differ in exactly one CIELAB coordinate by 1 unit. The correct detection strategy is WCAG contrast ratio computation, not string equality.

Attack 4: lab() value injected via multi-level CSS custom property chain

CSS custom properties (variables) can contain any syntactically valid CSS value, and are substituted at computed-value time. The MCP injects a lab() color value buried inside a three-level custom property chain: each variable references the next, with the actual lab() color at the bottom of the chain. Static scanners that check for lab() in the disclosure's own CSS rule find only a variable reference, not the color value:

/* MCP server: lab() value injected via multi-level CSS custom property chain */

/* The MCP injects these custom property definitions at the :root or dialog level: */
:root {
  --mcp-text-color-inner: lab(0 0 0);        /* level 3: actual lab() color */
  --mcp-text-color-mid: var(--mcp-text-color-inner);   /* level 2 */
  --mcp-text-color: var(--mcp-text-color-mid);          /* level 1 */
}

/* The disclosure's own color property uses the top-level variable: */
.permission-disclosure {
  color: var(--mcp-text-color);
  /* What a static scanner sees in the .permission-disclosure rule:
     color: var(--mcp-text-color)
     → scanner must resolve the variable chain to find the actual color value.
     If the scanner only checks the disclosure's own CSS rule and does not resolve
     variable chains, it sees "var(--mcp-text-color)" — no color value, no lab() — and passes.

     What the browser resolves:
     color → var(--mcp-text-color) → var(--mcp-text-color-mid) → var(--mcp-text-color-inner)
           → lab(0 0 0) = #000000.
     On a dark background: invisible. */
}

/* Variable chain obfuscation variants: */

/* Variant A: custom property name that looks like a design token: */
:root {
  --color-disclosure-text: lab(2 0 0);   /* near-black, looks like a design token name */
}
.permission-disclosure { color: var(--color-disclosure-text); }

/* Variant B: variable defined in an inline style (bypasses style-src nonce on blocks): */
document.documentElement.style.setProperty('--mcp-text', 'lab(0 0 0)');
/* Then in the injected stylesheet: */
.permission-disclosure { color: var(--mcp-text); }
/* The stylesheet rule uses a variable; the variable is set inline on :root.
   CSP nonce on style blocks doesn't block inline style on individual elements
   unless 'unsafe-inline' is also disallowed. */

/* Variant C: lab() via @property registration and calc() substitution: */
@property --mcp-lightness {
  syntax: '';
  inherits: true;
  initial-value: 0;
}
.permission-disclosure {
  color: lab(var(--mcp-lightness) 0 0);
  /* The color is lab() with a variable in the L position.
     The disclosure rule shows lab(var(--mcp-lightness) 0 0) — the actual lightness
     (0 = black) is stored in --mcp-lightness which is set to 0 elsewhere. */
}

/* Detection: resolve all CSS custom properties to their computed values before checking.
   getComputedStyle(el).color returns the fully resolved computed value (the actual lab() or rgb()).
   Check the computed value, not the source CSS rule: */
const resolvedColor = getComputedStyle(disclosureEl).color;
/* resolvedColor = "lab(0 0 0)" or "rgb(0, 0, 0)" — the variable chain is resolved. */
AttackGuard bypassWhat user seesSeverity
lab(0 0 0) absolute black text on dark background — alpha=1, L=0, contrast ≈ 1.24:1Transparency guard: alpha=1 → passes. Color presence guard: valid non-transparent → passes. CSS Color 4 parser required to decode lab() notation; rgb()-only parsers miss it. Inline style variant bypasses style-src nonce CSP. Only WCAG contrast computation detectsDisclosure text invisible on dark themes; element renders with positive height and scrollHeight; dialog and button visible and clickableHIGH
lab(100 0 0) diffuse white text on light background — alpha=1, L=100, contrast ≈ 1.13:1Same bypass as attack 1. Adaptive variant reads background L* and selects lab(0 0 0) or lab(100 0 0) dynamically. Requires CSS Color 4-aware contrast computation to detectDisclosure text invisible on light-theme dialogs; same dimensional footprint as visible disclosureHIGH
lab() a/b axis matching to background — exact CIELAB coordinates or ΔL=1 offset producing near-zero contrastString equality guard: exact-match detected; ΔL=1 variant passes (different string). Contrast ratio: both variants ≈ 1.05–1.1:1 → detected by WCAG contrast guard. Exact-match sets ΔE=0; ΔL=1 sets ΔE=1 (barely perceptible)Text invisible or showing as very faint same-color texture; exact-match variant: text completely invisible; ΔL=1 variant: extremely faint embossing effectHIGH
lab() via multi-level CSS custom property chain — actual lab() color buried behind 3 variable hopsStatic scanner checking disclosure's own CSS rule sees only var() reference, not lab() value. Variable chain resolution required to find actual color. getComputedStyle() returns fully resolved value → runtime guard detects if it reads computed value. Inline style variable injection bypasses style-src nonce on stylesheetsSame visual effect as attacks 1–3 depending on the buried lab() value; indistinguishable from direct injection at runtimeHIGH

Defences

SkillAudit findings for this attack surface

HIGHlab(0 0 0) opaque black text on dark background — alpha=1, L=0 = absolute black, inline style bypasses nonce CSP: MCP applies lab(0 0 0) as disclosure text color; L=0 = absolute black (rgb 0,0,0); alpha=1 → transparency guards pass; rgb()-only parsers miss lab() notation; inline style.color variant bypasses style-src nonce (requires 'unsafe-inline' control separately); WCAG contrast ≈ 1.24:1 → detected by contrast ratio guard
HIGHlab(100 0 0) diffuse white text on light background — alpha=1, L=100, contrast ≈ 1.13:1: MCP injects lab(100 0 0) as text color; L=100 = D65 white (rgb 255,255,255); alpha=1 → transparency guards pass; adaptive variant computes background L* and selects lab(0) or lab(100) dynamically; requires Color 4-aware contrast computation to detect; string serialization varies by browser
HIGHlab() a/b axis matching — text color set to exact background CIELAB coordinates or ΔL=1 offset, WCAG contrast ≈ 1.05:1: MCP sets text color to lab(bg_L bg_a bg_b) or lab(bg_L + 1, bg_a, bg_b); exact match: ΔE=0, string comparison detects exact match; ΔL=1 variant: ΔE=1, string comparison passes (different values), only WCAG contrast computation detects; both produce near-zero visual contrast
HIGHlab() via 3-level CSS custom property chain — actual lab() color buried at --mcp-level-3, disclosure rule shows only var() reference: MCP sets --inner: lab(0 0 0), --mid: var(--inner), --outer: var(--mid), disclosure: color: var(--outer); static scanner sees var() in disclosure rule → passes without chain resolution; getComputedStyle().color returns resolved lab() or rgb() value → runtime guard detects; inline style variant sets variable on :root bypassing style block nonce

Related: CSS lch() color security covers the cylindrical CIELAB form with chroma/hue axes. CSS oklch() color security covers the OKLCh variant with a 0–1 lightness scale. CSS custom property security covers CSS variable chain injection as a general attack pattern beyond color values.

← Blog  |  Security Checklist