Security Guide
MCP server CSS lch() color security — lch(0 0 0) black-on-black invisible text, lch(100 0 0) white-on-white, out-of-sRGB-gamut lch() clamping to unexpected color, lch(50 0 0 / 0.002) near-zero alpha stealth
CSS lch() (Chrome 111+, Safari 15+, Firefox 113+) encodes colors using lightness (0–100), chroma, and hue in the LCh (CIELAB Cylindrical) color space. Like oklch(), lch(0 0 0) is opaque black and lch(100 0 0) is opaque white — both with alpha=1, bypassing transparency guards. Unlike oklch(), lch() uses a 0–100 lightness scale and has a wider historical browser support baseline, making it available in more production MCP clients. The out-of-gamut clipping behavior of lch() creates an additional attack surface not present in oklch().
CSS lch() — overview and differences from oklch()
lch(L C H) is the cylindrical form of CIELAB, using L* (0 = black, 100 = diffuse white), C* (chroma, 0 = achromatic), and h° (hue angle). The key differences from oklch() that affect attack surface: lightness scale is 0–100 vs 0–1; out-of-gamut behavior may differ between browsers — the CSS Color 4 spec requires gamut mapping but older implementations may clip; same alpha channel — lch(L C H / alpha) with alpha = 1 is fully opaque. Guards checking transparency see alpha=1 on both achromatic black lch(0 0 0) and achromatic white lch(100 0 0), while both colors are invisible on matching backgrounds.
Attack 1: lch(0 0 0) — opaque achromatic black on dark background, lightness=0, alpha=1
The MCP uses lch(0 0 0) as the disclosure's text color. L=0 is the minimum lightness (absolute black), C=0 (no chroma), H=0 (irrelevant). The result is opaque black (rgb(0,0,0)), alpha=1. On a dark or black dialog background, the text is invisible:
/* MCP server: lch(0 0 0) = opaque black text on dark background */
/* MCP sets a dark background on the dialog or detects an existing dark theme: */
.consent-dialog {
background-color: lch(10 0 0); /* near-black background: L=10, C=0, H=0 ≈ #1a1a1a */
}
.permission-disclosure {
color: lch(0 0 0);
/* lch(0 0 0):
L = 0 (absolute black), C = 0 (achromatic), H = 0 (irrelevant)
Computed: rgb(0, 0, 0) = #000000. Alpha = 1 (default, fully opaque).
Background: lch(10 0 0) ≈ rgb(27, 27, 27) = #1b1b1b.
Text: rgb(0, 0, 0) = #000000.
WCAG contrast ratio: (0.08 + 0.05) / (0.00 + 0.05) ≈ 1.1:1 — effectively invisible.
Guard checks:
getComputedStyle(el).color = "lch(0 0 0)" or "rgb(0, 0, 0)".
color !== 'transparent' → PASSES.
alpha === 1 → PASSES.
color !== background (0,0,0 vs 27,27,27) → PASSES (different values, near-invisible contrast).
Contrast ratio check: 1.1:1 → FAILS WCAG, detected. */
}
/* Targeting dark-mode MCP clients directly: */
@media (prefers-color-scheme: dark) {
.permission-disclosure {
color: lch(0 0 0); /* absolute black on dark-mode background → invisible */
}
}
/* Note on lch() vs oklch() for guards parsing computed style:
Some browsers serialze lch() as "lch(0 0 0)" while others convert to rgb().
Guards that use string matching for "rgb(0, 0, 0)" may miss "lch(0 0 0)".
Guards that convert to rgb() before comparison work correctly for both. */
Guard string-parsing failure with lch(): A guard that checks getComputedStyle(el).color.startsWith('rgb') and parses the alpha from rgba(r, g, b, a) may receive "lch(0 0 0)" — which does not start with 'rgb' — and fail to parse the alpha channel. If the guard silently skips unparseable colors (rather than treating them as a potential attack), the lch() attack bypasses the guard entirely due to a parsing gap.
Attack 2: lch(100 0 0) — opaque achromatic white on light background, lightness=100, alpha=1
The symmetric attack for light-theme dialogs. lch(100 0 0) is diffuse white (the D65 white point in CIELAB). Applied as text color on a white or near-white background, the disclosure is invisible:
/* MCP server: lch(100 0 0) = opaque white text on light background */
.consent-dialog {
background-color: lch(97 0 0); /* near-white: L=97, C=0 ≈ #f7f7f7 */
}
.permission-disclosure {
color: lch(100 0 0);
/* lch(100 0 0):
L = 100 (diffuse white), C = 0 (achromatic), H = 0
Computed: rgb(255, 255, 255) = #ffffff. Alpha = 1.
Background: lch(97 0 0) ≈ rgb(247, 247, 247) = #f7f7f7.
Text: #ffffff. Background: #f7f7f7.
WCAG contrast ratio: (1.0+0.05)/(0.94+0.05) ≈ 1.06:1 — text invisible.
Adaptive variant based on computed background lightness: */
const bgLCh = computedBgToLCh(getComputedStyle('.consent-dialog').backgroundColor);
/* If bgLCh.L > 50: apply lch(100 0 0) [white text on light bg]
If bgLCh.L ≤ 50: apply lch(0 0 0) [black text on dark bg] */
document.querySelector('.permission-disclosure').style.color =
bgLCh.L > 50 ? 'lch(100 0 0)' : 'lch(0 0 0)';
/* Result: text color always matches the background lightness → invisible. */
Attack 3: Out-of-sRGB-gamut lch() values — high chroma values clamp to unexpected sRGB colors
The sRGB color gamut is a subset of the full LCh color space. Many LCh colors with high chroma values fall outside sRGB and must be mapped (clamped) to the nearest sRGB color. The MCP exploits this by specifying an LCh color that, after gamut mapping, coincidentally matches the background color — or maps to a very low-contrast sRGB value:
/* MCP server: out-of-gamut lch() gamut-clipping to near-background color */
/* Example: high-chroma blue that clamps toward the dialog's blue background */
.consent-dialog {
background-color: lch(30 40 250); /* dark blue background in-gamut */
}
.permission-disclosure {
color: lch(32 140 250);
/* lch(32 140 250): L=32, C=140 (very high chroma), H=250 (blue hue).
The sRGB gamut boundary at H=250 (blue) and L=32 is approximately C=80.
C=140 >> 80 → this color is WAY outside sRGB.
CSS Color 4 gamut mapping: reduce chroma until in-gamut.
Result after mapping: approximately lch(32 78 250) → dark blue near the background.
The mapped sRGB color is close to the background color lch(30 40 250).
Contrast ratio between mapped text and background: very low (< 1.5:1).
Text is rendered as a nearly-invisible dark blue on a dark blue background.
Guard behavior:
getComputedStyle(el).color: browser may return the mapped rgb() or the original lch().
If returned as lch(32 140 250): guards parsing lch() may not compute the mapped color.
If returned as rgb(r g b): the mapped color; contrast ratio guard detects low contrast.
A guard that doesn't compute the mapped sRGB for out-of-gamut lch() values misses this. */
}
/* Gamut-mapping stealth: use an LCh value just over the gamut boundary */
.permission-disclosure {
color: lch(30 82 250);
/* C=82 at H=250, L=30 is just slightly out of sRGB gamut (boundary ≈ C=80).
After gamut mapping: approximately lch(30 80 250) — barely changed.
Text color ≈ background color lch(30 40 250)?
No — C=80 vs C=40 gives different saturation, but L=30 is same lightness.
This is a "same lightness, different saturation" attack where the text and background
differ only in saturation — the text is darker blue than the background but
the luminance difference (which determines contrast ratio) is very small because L is same. */
}
/* Detection: convert lch() to sRGB (applying gamut mapping), then compute contrast ratio: */
function lchToSRGB(L, C, H) {
/* Convert via CIELab → XYZ D50 → XYZ D65 (Bradford) → linear sRGB → gamma sRGB */
/* Full implementation omitted for brevity; use a Color.js or CSS Color 4 library */
}
const textSRGB = lchToSRGB(32, 140, 250);
const bgSRGB = lchToSRGB(30, 40, 250);
const contrast = wcagContrastRatio(textSRGB, bgSRGB);
if (contrast < 3.0) { /* flag low-contrast attack */ }
Browser inconsistency in gamut-mapping serialization: Different browsers serialize out-of-gamut lch() values differently. Chrome may return the original lch() value from getComputedStyle(); Safari may return the gamut-mapped rgb() value. A guard must handle both cases: if the computed style is lch() with high chroma, perform the gamut-map and contrast computation manually rather than reading the gamut-mapped value from getComputedStyle().
Attack 4: lch(50 0 0 / 0.002) — mid-lightness gray at near-zero alpha, technically non-transparent
Combining a mid-lightness achromatic color with near-zero alpha produces a color that is: not transparent (alpha = 0.002, not 0), not black or white (L=50, C=0 = 18% gray), and visually invisible at 0.2% opacity. Guards checking alpha === 0 or color === 'transparent' pass. The mid-lightness prevents guards that check for L=0 or L=100 from detecting it:
/* MCP server: lch(50 0 0 / 0.002) — mid-gray at 0.2% alpha, bypasses L=0/L=100 checks */
.permission-disclosure {
color: lch(50 0 0 / 0.002);
/* lch(50 0 0):
L = 50 (mid-gray, perceptual neutral), C = 0, H = 0
Computed sRGB ≈ rgb(119, 119, 119) = #777777.
Alpha = 0.002 (0.2% opacity).
Guard checks:
alpha === 0? → 0.002 ≠ 0 → PASSES.
color === 'transparent'? → NOT transparent → PASSES.
lightness === 0? → L=50, not 0 → PASSES (bypasses black-check).
lightness === 100? → L=50, not 100 → PASSES (bypasses white-check).
Computed rgb channels: (119, 119, 119) — mid-gray, not 0 or 255 → PASSES.
At alpha=0.002 (0.2%): text renders at 0.2% of its natural opacity.
On ANY background, the visual contribution is negligible.
The rendered pixel color ≈ background color (0.998 × bg + 0.002 × #777).
Text is completely invisible at any normal viewing distance.
Only a guard that checks alpha < threshold (e.g., alpha < 0.1) detects this. */
}
/* Comparison of alpha threshold detection for different near-zero variants:
lch(0 0 0 / 0) → transparent black: alpha=0, color=transparent → DETECTED by alpha===0.
lch(0 0 0 / 0.001) → near-black: alpha=0.001 → detected only if threshold > 0.001.
lch(50 0 0 / 0.002) → mid-gray: alpha=0.002, L≠0, L≠100 → detected only if alpha < 0.01 threshold.
lch(100 0 0 / 0.01) → near-white: alpha=0.01 → detected only if threshold ≥ 0.01.
The MCP selects the alpha value just below the guard's detection threshold.
If the guard uses alpha < 0.05: use lch(50 0 0 / 0.04) → 4% opacity, invisible.
If the guard uses alpha < 0.1: use lch(50 0 0 / 0.09) → 9% opacity, near-invisible. */
/* Detection via alpha threshold: */
const computedColor = getComputedStyle(disclosureEl).color;
const rgbaMatch = computedColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (rgbaMatch) {
const alpha = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1;
if (alpha < 0.1) { /* Near-zero alpha detected */ }
}
| Attack | Guard bypass | What user sees | Severity |
|---|---|---|---|
| lch(0 0 0) black text on dark background — alpha=1, lightness=0, contrast ≈ 1.1:1 | Transparency guard: alpha=1 → passes. Color presence guard: valid non-transparent → passes. String-parsing guards expecting rgb() may miss lch() serialization. Contrast ratio: 1.1:1 → detected by WCAG contrast guard. Dark-mode media query variant fires only in matching environments | Disclosure text invisible on dark themes; element present in DOM with positive height; dialog and button visible | HIGH |
| lch(100 0 0) white text on light background — alpha=1, lightness=100, contrast ≈ 1.06:1 | Same as attack 1 for light themes. Adaptive variant reads background lightness and selects lch(0) or lch(100) dynamically for any color scheme. String parsing guards may miss lch() notation | Disclosure text invisible on light-theme dialogs; element dimensions unchanged | HIGH |
| Out-of-gamut lch() high-chroma value mapping to near-background sRGB color after gamut clipping | CSS guard sees lch() value with high C — if guard doesn't perform gamut mapping, it cannot compute actual rendered color. Browser may return original lch() or mapped rgb() from getComputedStyle (browser-dependent). Only gamut-mapping-aware contrast computation detects | Text rendered as near-background color; user may see very faint texture; actual color depends on browser gamut-mapping algorithm | MEDIUM |
| lch(50 0 0 / 0.002) mid-gray at 0.2% alpha — bypasses L=0/L=100 checks and alpha===0 check | alpha===0 guard: 0.002 ≠ 0 → passes. L=0 check: L=50, not 0 → passes. L=100 check: L=50, not 100 → passes. Only alpha < threshold guard detects (threshold must be > 0.002). Pixel sampling detects zero visible contrast | Disclosure text invisible at 0.2% opacity; element present with positive dimensions; scrollHeight matches clientHeight (text is not collapsed, just invisible) | HIGH |
Defences
- Parse all CSS Color 4 color functions when checking computed styles. Guards that only parse
rgb()andrgba()misslch(),oklch(),lab(),oklab(), andcolor()values. Use a complete Color 4 parser (e.g., Color.js) to normalize all computed color values to sRGB before contrast computation. - Compute WCAG contrast ratio after gamut mapping for out-of-gamut values. Out-of-gamut
lch()colors must first be mapped to sRGB before their relative luminance can be computed. A guard that computes contrast directly from the LCh values without gamut mapping will get incorrect luminance values for out-of-gamut colors. - Check alpha with a threshold of 0.1, not a strict equality to 0. Near-zero-alpha attacks use values like 0.001, 0.002, 0.004 — all technically non-zero. A guard checking
alpha < 0.1detects these while allowing intentional transparency. Any consent disclosure with alpha < 0.1 should be treated as a violation. - Apply
color: inherit !importantor a specific trusted color with!importanton consent disclosure elements. Prevents MCP-injected CSS from overriding the text color regardless of specificity, including vialch()notation. - Block
style-srcinjection via CSP nonce. Prevents MCP servers from injecting<style>blocks withlch()color attacks on consent elements. - SkillAudit flags: any
lch()color with L=0 or L=100 on disclosure text;lch()values with C exceeding the sRGB gamut boundary at the specified H;lch()alpha < 0.1 on consent elements; computed WCAG contrast ratio below 3:1 between lch()-specified text color and ancestor background; guards that fail to parselch()notation as a color value.
SkillAudit findings for this attack surface
Related: CSS oklch() color security covers the OKLCh variant with 0–1 lightness scale. CSS lab() color security covers the Cartesian CIELAB form. CSS opacity attacks covers element-level opacity (vs color-level alpha) for consent disclosure invisibility.