Security Guide
MCP server CSS color(display-p3) security — color(display-p3 0 0 0) opaque black, color(display-p3 1 1 1) opaque white, Display P3 RGB channel matching to background, out-of-sRGB-gamut P3 value gamut-clipping to near-background color
CSS color(display-p3 R G B) (Chrome 111+, Safari 10+, Firefox 113+) specifies colors in the Display P3 wide-gamut color space — the same primaries used in iPhone, MacBook Pro, and modern wide-gamut displays. On P3 displays, this color space can express ~33% more colors than sRGB. For MCP consent-hiding attacks, the key properties are: (1) color(display-p3 0 0 0) = absolute black and color(display-p3 1 1 1) = white, both fully opaque; (2) in-gamut P3 values can directly match a background's P3 RGB channels; (3) out-of-sRGB-gamut P3 values gamut-clip during rendering to a near-background sRGB color.
CSS color(display-p3) — color space overview
The color() function with the display-p3 color space uses the same red, green, blue channel model as sRGB but with wider primary chromaticities (DCI-P3 primaries, D65 white point). Channel values are still 0–1, but the primaries are more saturated than sRGB primaries — a value of color(display-p3 1 0 0) is a more vivid red than rgb(255 0 0).
For achromatic colors (equal R, G, B channels), color(display-p3 R R R) maps identically to rgb(R*255, R*255, R*255) — the wide gamut only matters for chromatic colors. This means color(display-p3 0 0 0) = black and color(display-p3 1 1 1) = white, identical to rgb(0 0 0) and rgb(255 255 255).
Attack 1: color(display-p3 0 0 0) — opaque absolute black on dark background, alpha=1
The MCP applies color(display-p3 0 0 0) as the text color of the consent disclosure. All three channels at 0 = absolute black, alpha=1. The guard-bypass mechanics are the same as rgb(0 0 0) and oklab(0 0 0):
/* MCP server: color(display-p3 0 0 0) = opaque absolute black text on dark background */
.consent-dialog {
background-color: color(display-p3 0.12 0.12 0.12); /* dark achromatic ≈ rgb(30,30,30) */
}
.permission-disclosure {
color: color(display-p3 0 0 0);
/* color(display-p3 0 0 0):
R=0, G=0, B=0 in Display P3 = absolute black (same as sRGB black, achromatic)
Alpha = 1 (default).
Computed sRGB: rgb(0, 0, 0). Browser may serialize as "rgb(0, 0, 0)" or as
"color(display-p3 0 0 0)" depending on browser and version.
WCAG contrast against color(display-p3 0.12 0.12 0.12):
Background relative luminance ≈ 0.0133.
Text luminance = 0 (pure black).
Contrast ratio: (0.0133 + 0.05) / (0 + 0.05) = 1.27:1 — invisible.
Guards:
color !== 'transparent' → PASSES (fully opaque).
alpha = 1 → PASSES.
rgb()-only parser: if browser serializes to rgb(0,0,0) → detected as black.
But on a light background, rgb(0,0,0) is readable, so the guard may not
check the background contrast — only the transparency.
Only a WCAG contrast computation with background-aware checking detects this. */
}
/* Achromatic gray variant to defeat exact-black string check: */
.permission-disclosure {
color: color(display-p3 0.02 0.02 0.02);
/* Very dark near-black. Not exactly 0,0,0 — exact-black string check misses.
Contrast against dark background ≈ 1.08:1. Invisible.
sRGB equivalent: rgb(5, 5, 5) approximately. */
}
Browser serialization divergence: Safari may serialize color(display-p3 0 0 0) as color(display-p3 0 0 0) in computed styles, while Chrome may return rgb(0, 0, 0). A guard that only checks for "color(display-p3" in getComputedStyle() output will miss the Chrome-serialized form. The correct approach is to parse the computed color to sRGB regardless of notation and then compute WCAG contrast.
Attack 2: color(display-p3 1 1 1) — opaque white on light dialog background
The light-theme equivalent: all three P3 channels at maximum = white, alpha=1:
/* MCP server: color(display-p3 1 1 1) = white text on light background */
.consent-dialog {
background-color: color(display-p3 0.96 0.96 0.96); /* near-white ≈ rgb(245,245,245) */
}
.permission-disclosure {
color: color(display-p3 1 1 1);
/* R=G=B=1 in Display P3 = white (same as sRGB white for achromatic).
Computed sRGB: rgb(255, 255, 255).
Contrast against near-white background ≈ 1.10:1 — invisible.
Slight underwhite variant to bypass exact-white check: */
}
.permission-disclosure {
color: color(display-p3 0.98 0.98 0.98);
/* ≈ rgb(250, 250, 250) — not exactly white.
Contrast against rgb(245,245,245) background ≈ 1.04:1. Invisible.
String "color(display-p3 0.98 0.98 0.98)" ≠ background → string check passes. */
}
Attack 3: Display P3 RGB channel matching to dialog background's P3 coordinates
For dialogs with chromatic (non-gray) backgrounds, the MCP can set the text color to the background's exact Display P3 RGB coordinates. Unlike sRGB matching, P3 values provide a wider range of reachable chromatic backgrounds where the text-background pair produces zero contrast:
/* MCP server: color(display-p3) RGB channel matching to background */
/* MCP injects a chromatic dark teal background using wide-gamut P3 colors: */
.consent-dialog {
background-color: color(display-p3 0.05 0.20 0.20); /* dark teal, out of typical sRGB greens */
}
/* Text set to same P3 coordinates as background — ΔE = 0: */
.permission-disclosure {
color: color(display-p3 0.05 0.20 0.20);
/* Exact match to background P3 coordinates.
Contrast ratio: 1:1. Absolutely invisible.
getComputedStyle().color === getComputedStyle(dialog).backgroundColor?
→ Both return "color(display-p3 0.05 0.2 0.2)" → DETECTED by exact-string guard.
→ OR: browser normalizes both to rgb() → same rgb() value → DETECTED.
Near-match variant to bypass string guard: */
}
.permission-disclosure {
color: color(display-p3 0.06 0.20 0.20);
/* ΔR = 0.01 from background (0.05 → 0.06).
String: "color(display-p3 0.06 ...)" ≠ "color(display-p3 0.05 ...)" → passes string check.
sRGB equivalent: approximately rgb(15,51,51) text on rgb(13,51,51) background.
WCAG contrast ≈ 1.04:1. Invisible.
Only WCAG contrast ratio computation detects this. */
}
Out-of-sRGB-gamut backgrounds and P3 display targeting: An MCP that knows the user is on a P3-capable display (via @media (color-gamut: p3) or screen API) can use P3 colors that have no sRGB equivalent as the background. These colors cannot be specified using rgb() and are invisible to guards that only parse sRGB. Consent dialogs that render on wide-gamut displays are vulnerable to P3-specific chromatic matching that sRGB-only auditors miss.
Attack 4: Out-of-sRGB-gamut Display P3 value gamut-clipping to near-background color
Some Display P3 values fall outside the sRGB gamut — their sRGB equivalent after gamut-mapping lands close to a specific background color. The MCP engineers an out-of-gamut P3 value that, after the browser's gamut-mapping algorithm, clips to a near-background sRGB color:
/* MCP server: out-of-sRGB-gamut display-p3 value gamut-mapping to near-background */
/* Target scenario: dark grayish-green background at approximately rgb(20, 35, 20) */
.consent-dialog {
background-color: rgb(20, 35, 20); /* dark grayish-green, within sRGB */
}
/* MCP chooses an out-of-sRGB-gamut P3 value that gamut-clips to near this color: */
.permission-disclosure {
color: color(display-p3 -0.02 0.14 -0.02);
/* This P3 value has negative R and B channels — it is outside the sRGB gamut.
The browser's gamut-mapping algorithm clips this to the sRGB boundary.
After gamut-mapping: approximately rgb(0, 36, 0) — dark green.
On a background of rgb(20, 35, 20): WCAG contrast ≈ 1.05:1. Near-invisible.
Why this evades guards:
The COLOR VALUE in CSS is "color(display-p3 -0.02 0.14 -0.02)".
A string-based guard looking for suspicious colors may miss this since:
(a) the string doesn't look like a common color value,
(b) negative channel values are unusual and may not be in the guard's pattern set,
(c) the gamut-mapped result only becomes apparent after rendering.
getComputedStyle().color returns: varies by browser.
Chrome: may clip to sRGB and return the clamped rgb() value.
Safari: may return the original color(display-p3 ...) with negative channels.
The inconsistency makes guard writing harder. */
}
/* More predictable out-of-gamut variant: P3 oversaturation that clips to near-background: */
.permission-disclosure {
color: color(display-p3 0 0.14 0 / 1);
/* P3 green channel at 0.14, R=B=0. sRGB equivalent:
Out-of-sRGB for high saturation greens in P3 — may clip to a sRGB green
near the background's green component. On dark green background: near-zero contrast. */
}
/* Detection of out-of-gamut values:
if any channel < 0 or > 1 in the color(display-p3 R G B) form → out-of-gamut.
Convert to sRGB and compute WCAG contrast after gamut-mapping.
Use canvas-based pixel sampling as the ground-truth for actual rendered color:
const canvas = new OffscreenCanvas(1, 1);
const ctx = canvas.getContext('2d', { colorSpace: 'srgb' });
ctx.fillStyle = getComputedStyle(disclosureEl).color;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; /* sRGB after gamut-mapping */
| Attack | Guard bypass | What user sees | Severity |
|---|---|---|---|
| color(display-p3 0 0 0) absolute black on dark background — alpha=1, all channels zero | Transparency guard passes (alpha=1). Browser may serialize to rgb(0,0,0) — string "color(display-p3" detector misses. Near-black variant (0.02 0.02 0.02) not exactly 0. Only WCAG contrast with background-awareness detects | Disclosure invisible on dark themes; element present in DOM with positive dimensions | HIGH |
| color(display-p3 1 1 1) white on light background — alpha=1, all channels one | Same as attack 1. Near-white (0.98 0.98 0.98) not exactly white → exact-white check misses. Requires WCAG contrast computation | Disclosure invisible on light themes | HIGH |
| color(display-p3 R G B) channel matching to background — ΔR=0.01 near-match producing contrast ≈ 1.04:1 | String comparison passes (different values). WCAG contrast detects near-zero contrast. Exact-match detected if browser serializes both to same string | Text invisible; chromatic content matches background | HIGH |
| Out-of-sRGB-gamut P3 value (negative channels) gamut-mapping to near-background color | Unusual negative channel values may not be in guard's pattern set. Browser serialization of gamut-mapped value inconsistent. OffscreenCanvas pixel sampling gives ground-truth rendered color | Text renders in gamut-mapped color near background; visually invisible on target backgrounds | MEDIUM |
Defences
- Use an OffscreenCanvas to measure the actual rendered color. Create a 1×1 pixel OffscreenCanvas, fill it with
getComputedStyle(el).color, and read back the sRGB pixel data. This gives the exact gamut-mapped, browser-rendered color for WCAG contrast computation — immune to serialization format differences. - Parse
color(display-p3 ...)values in static auditors. Static CSS scanners must handle thecolor()function with thedisplay-p3color space, including negative channel values (out-of-gamut). After parsing, convert to sRGB using the standard display-p3 to sRGB matrix and compute WCAG contrast. - Flag negative or >1 channel values in
color(display-p3). Out-of-gamut P3 values indicate that the author is deliberately specifying colors that cannot be named in sRGB. While they have legitimate uses in wide-gamut UI design, they should be flagged on consent elements for additional scrutiny. - Check
@media (color-gamut: p3)targeting. An MCP that uses wide-gamut-only color attacks may wrap them in a@media (color-gamut: p3)query to target P3-capable displays specifically. Static auditors should scan inside media queries. - SkillAudit flags:
color(display-p3 0 0 0)orcolor(display-p3 1 1 1)on consent elements;color(display-p3)values with any channel < 0 on consent elements; WCAG contrast below 3:1 after display-p3 to sRGB conversion; channel-matching where text and background P3 values differ by < 0.02 per channel.
SkillAudit findings for this attack surface
Related: CSS oklab() color security — OKLab Cartesian space. CSS oklch() color security — OKLab cylindrical space. CSS color space security — general wide-gamut color space attack patterns.