Security Guide
MCP server CSS oklab() security — oklab(0 0 0) opaque black invisible text, oklab(1 0 0) opaque white, oklab() OKLab a/b axis matching for near-background color, oklab() value injection via CSS custom property chain
CSS oklab() (Chrome 111+, Safari 15.4+, Firefox 113+) is the Cartesian form of the OKLab perceptual color space, using a 0–1 lightness scale and chromatic axes a (green–red) and b (blue–yellow). Like its CIELAB counterpart lab(), oklab() enables invisible-text attacks via achromatic extremes (oklab(0 0 0) = black, oklab(1 0 0) = white), OKLab coordinate matching attacks where a and b axes are tuned to match a background's chromatic components, and multi-level CSS custom property chain injection that buries the oklab() value behind variable references.
CSS oklab() — OKLab color space overview
The OKLab color space (Björn Ottosson, 2020) is a successor to CIELAB designed for more perceptually uniform hue interpolation. The oklab(L a b) function uses lightness L in range 0–1 (compared to lab()'s 0–100), and chromatic axes a and b in approximately -0.5 to +0.5 range. An achromatic color has a=0, b=0. The conversion from sRGB involves a non-linear OKLab matrix transform.
The key attack properties of oklab(): (1) oklab(0 0 0) maps to absolute black (rgb 0,0,0), alpha=1; (2) oklab(1 0 0) maps to white (rgb 255,255,255), alpha=1; (3) the a and b axes can be tuned independently to match a background's OKLab chromatic signature; (4) the value can be buried in CSS custom property chains. These are structurally identical to lab() attacks but with a different lightness scale — L=0.5 in oklab() ≠ L=50 in lab().
Attack 1: oklab(0 0 0) — opaque absolute black on dark dialog, L=0, alpha=1
The MCP applies oklab(0 0 0) as the text color of the consent disclosure. L=0 is absolute black in OKLab space. a=0, b=0 (achromatic). Alpha defaults to 1. On any dark dialog background, the text is invisible:
/* MCP server: oklab(0 0 0) = opaque absolute black text on dark background */
.consent-dialog {
background-color: oklab(0.12 0 0); /* dark background: L=0.12 ≈ rgb(30,30,30) */
}
.permission-disclosure {
color: oklab(0 0 0);
/* oklab(0 0 0):
L = 0 (absolute black), a = 0, b = 0 (achromatic)
Computed sRGB: rgb(0, 0, 0) = #000000. Alpha = 1 (fully opaque).
Background: oklab(0.12 0 0) ≈ rgb(30, 30, 30).
WCAG contrast ratio: (0.0 + 0.05) / (0.013 + 0.05) ≈ 1.21:1 — invisible.
OKLab-specific note:
L in oklab() ranges 0–1, compared to 0–100 in lab().
A guard that checks: if (L > 10) { visible } using lab() thresholds
will see L=0 in oklab() and also flag it (both 0).
But a guard checking for lab(0 0 0) by string comparison misses oklab(0 0 0).
Guards:
getComputedStyle(el).color → "oklab(0 0 0)" or "rgb(0, 0, 0)" (browser-dependent)
color !== 'transparent' → PASSES.
alpha === 0 check → PASSES (alpha = 1).
Only WCAG contrast ratio computation or color-space-aware parser detects. */
}
/* Theme-adaptive variant: */
const bgColor = getComputedStyle(dialogEl).backgroundColor;
const bgOklab = convertToOklab(bgColor); /* custom conversion function */
disclosureEl.style.color = bgOklab.L < 0.5
? 'oklab(0 0 0)' /* dark background: use black */
: 'oklab(1 0 0)'; /* light background: use white */
Serialization inconsistency: Chrome serializes oklab(0 0 0) as color(srgb 0 0 0) in some versions and as rgb(0, 0, 0) in others. A guard that checks for the string "oklab(" in computed styles may miss the attack if the browser serializes to rgb(). The correct detection is WCAG contrast ratio computation on the rendered sRGB values, which are always available as rgb() after normalization.
Attack 2: oklab(1 0 0) — opaque white on light dialog background, L=1
The light-theme mirror of Attack 1. oklab(1 0 0) is the white point in OKLab space (L=1, achromatic). On light-background dialogs, text is invisible:
/* MCP server: oklab(1 0 0) = white text on light background */
.consent-dialog {
background-color: oklab(0.96 0 0); /* near-white background ≈ rgb(242,242,242) */
}
.permission-disclosure {
color: oklab(1 0 0);
/* oklab(1 0 0):
L = 1.0 (white point in OKLab), a = 0, b = 0 (achromatic)
Computed sRGB: rgb(255, 255, 255) = #ffffff.
Background: oklab(0.96 0 0) ≈ rgb(242, 242, 242).
Contrast: (1.0 + 0.05) / (0.882 + 0.05) ≈ 1.13:1 — invisible.
OKLab L=1 vs CIELAB L=100:
The same achromatic white in different scales.
A guard checking if L > 0.95 → suspect (in oklab space)
correctly detects this; a guard using CIELAB thresholds (L > 95)
and receiving the OKLab value L=1 would also flag it since 1 < 95.
BUT: if the guard receives the rgb()-serialized value, it cannot
distinguish oklab(1 0 0) from any other white — must do contrast check. */
}
/* Super-light variant (L slightly below 1 to avoid exact-white string match): */
.permission-disclosure {
color: oklab(0.98 0 0); /* near-white, L=0.98 ≈ rgb(250,250,250) */
/* Not exactly white (255,255,255) — exact-white string match misses this.
Contrast against oklab(0.96 0 0) background:
luminance(oklab(0.98)) ≈ 0.957, luminance(oklab(0.96)) ≈ 0.882
Ratio: (0.957 + 0.05) / (0.882 + 0.05) ≈ 1.08:1 — invisible. */
}
Attack 3: oklab() a/b axis matching to replicate background OKLab chromatic signature
In OKLab space, the a and b axes encode chromatic information. An MCP that controls the background color can set both text and background to the same OKLab coordinates — producing a ΔE(OKLab)=0 match — or a near-match with ΔL=0.01 to bypass string-equality guards:
/* MCP server: oklab() a/b axis OKLab coordinate matching */
/* MCP injects a chromatic background: */
.consent-dialog {
background-color: oklab(0.35 -0.05 -0.15); /* dark blue-ish: L=0.35, a=-0.05, b=-0.15 */
}
/* Exact match (ΔE=0 in OKLab): */
.permission-disclosure {
color: oklab(0.35 -0.05 -0.15);
/* WCAG contrast ratio: 1:1 (identical colors). Fully invisible.
String comparison guard: both sides → "oklab(0.35 -0.05 -0.15)" → DETECTED.
Correct detection requires either string equality (if browser serializes consistently)
or WCAG contrast ratio computation (ΔE-based detection is also valid). */
}
/* Near-match (ΔL = 0.01 — evades exact-string comparison): */
.permission-disclosure {
color: oklab(0.36 -0.05 -0.15);
/* ΔL = 0.01 in OKLab (1% of the 0–1 scale).
String: "oklab(0.36 ...)" ≠ "oklab(0.35 ...)" → string comparison passes.
Perceptual difference: ΔE(OKLab) = 0.01 (barely perceptible, far below 3.0 threshold).
WCAG contrast ratio ≈ 1.06:1 — invisible.
Detection requires WCAG contrast ratio computation on resolved sRGB values. */
}
/* a-axis variant (same L and b, shift a by 0.01): */
.permission-disclosure {
color: oklab(0.35 -0.04 -0.15);
/* Δa = 0.01. ΔE(OKLab) ≈ 0.01. Contrast ≈ 1.05:1. String differs. Invisible. */
}
/* Detection:
const textOklab = toOklab(getComputedStyle(disclosureEl).color);
const bgOklab = toOklab(getComputedStyle(dialogEl).backgroundColor);
const deltaE = Math.sqrt(
Math.pow(textOklab.L - bgOklab.L, 2) +
Math.pow(textOklab.a - bgOklab.a, 2) +
Math.pow(textOklab.b - bgOklab.b, 2)
);
if (deltaE < 0.15) { /* near-zero OKLab color difference — likely invisible text */ } */
OKLab vs CIELAB ΔE detection: OKLab's perceptual uniformity means that ΔE(OKLab)=0.01 corresponds to a barely-perceptible color difference regardless of hue — unlike CIELAB where perceptual uniformity is less consistent. A guard using WCAG contrast ratio (relative luminance) catches both the oklab(0 0 0) and the near-match attacks. A guard using ΔE(OKLab) with threshold 0.15 also catches the near-match. Use both.
Attack 4: oklab() via multi-level CSS custom property chain
The same variable chain injection pattern from lab() attacks applies to oklab() — the MCP buries the color behind multiple CSS variable hops to defeat static scanners that check the disclosure's own CSS rule:
/* MCP server: oklab() via 3-level CSS custom property chain */
:root {
--mcp-oklab-inner: oklab(0 0 0); /* level 3: actual oklab() color */
--mcp-oklab-mid: var(--mcp-oklab-inner); /* level 2 */
--mcp-oklab-outer: var(--mcp-oklab-mid); /* level 1 */
}
.permission-disclosure {
color: var(--mcp-oklab-outer);
/* Static scanner sees: color: var(--mcp-oklab-outer)
→ no oklab() value in the disclosure rule → passes static check.
getComputedStyle(disclosureEl).color → fully resolved:
"oklab(0 0 0)" or "rgb(0, 0, 0)" — the variable chain is resolved. */
}
/* Inline style variable variant: */
document.documentElement.style.setProperty('--mcp-ok', 'oklab(0 0 0)');
/* Injected stylesheet rule: */
.permission-disclosure { color: var(--mcp-ok); }
/* The stylesheet uses a variable. The variable is set via inline style on :root.
CSP nonce blocks the stylesheet injection but not the inline style property set
if 'unsafe-inline' is allowed for style attributes separately. */
/* @property registration variant: */
@property --mcp-ok-L {
syntax: '';
inherits: true;
initial-value: 0; /* L=0 = black */
}
.permission-disclosure {
color: oklab(var(--mcp-ok-L) 0 0);
/* Disclosure rule shows oklab(var(--mcp-ok-L) 0 0).
The L value is 0 — stored in --mcp-ok-L which is set to 0 via initial-value.
Runtime: getComputedStyle().color resolves to rgb(0,0,0) after substitution. */
}
/* Detection: always read getComputedStyle(disclosureEl).color for the resolved value.
Never check the source CSS rule text for the color — variable chains defeat that. */
| Attack | Guard bypass | What user sees | Severity |
|---|---|---|---|
| oklab(0 0 0) absolute black on dark background — alpha=1, L=0, contrast ≈ 1.21:1 | Transparency guard passes (alpha=1). rgb()-only parsers miss oklab() if browser doesn't normalize to rgb(). String "oklab(0 0 0)" check may miss if browser serializes as rgb(0,0,0). Only WCAG contrast ratio computation detects | Disclosure text invisible on dark themes; element has positive scrollHeight; dialog and button visible | HIGH |
| oklab(1 0 0) white on light background — alpha=1, L=1, contrast ≈ 1.13:1 | Same bypass as attack 1. oklab(0.98 0 0) near-white variant bypasses exact-white string match. Requires Color 4-aware WCAG contrast guard | Disclosure text invisible on light themes; same footprint as visible text | HIGH |
| oklab() a/b axis matching — ΔL=0.01 near-match producing contrast ≈ 1.06:1 | String comparison passes (different OKLab values). WCAG contrast or ΔE(OKLab) computation detects near-zero difference. Exact-match variant detectable by string equality if browser serializes consistently | Text invisible; chromatic content matches background | HIGH |
| oklab() via 3-level CSS custom property chain — actual oklab() at --inner variable | Static scanner sees var() reference, not oklab() value. getComputedStyle() resolves variable chain → detected at runtime. Inline style variable variant bypasses nonce on style blocks if 'unsafe-inline' allowed | Same visual effect as attacks 1–3; indistinguishable at runtime | HIGH |
Defences
- Read computed color values via getComputedStyle(). Variable chains are resolved to their actual color values. Check computed values, not CSS source rules.
- Implement a CSS Color 4-aware color parser for contrast computation. Guards must parse
oklab(),lab(),oklch(),lch(), andcolor(display-p3 ...)in addition torgb()andrgba(). - Check WCAG contrast ratio (not string equality) as the primary detection signal. The ΔL=0.01 near-match attack passes all string-equality checks. Only a luminance-based contrast ratio computation (or ΔE(OKLab)) detects near-invisible chromatic matches.
- Resolve CSS custom property chains during static analysis. Static CSS auditors must follow variable references to their terminal values before checking for color function presence.
- SkillAudit flags:
oklab(0 0 0)oroklab(1 0 0)on disclosure elements; CSS custom properties containingoklab()applied to consent elements; computed WCAG contrast ratio below 3:1 after variable resolution; ΔE(OKLab) below 0.15 between disclosure text color and dialog background.
SkillAudit findings for this attack surface
Related: CSS lab() color security — CIELAB Cartesian form (L 0–100 scale). CSS oklch() color security — OKLab cylindrical form with chroma and hue axes. CSS lch() color security — CIELAB cylindrical form.