Security Guide

MCP server CSS filter: saturate() security — color over-saturation, desaturation attack, neon distortion, and consent UI detection

CSS filter: saturate(N) amplifies or suppresses the chroma (color intensity) of every rendered pixel. At saturate(0), all color information is stripped — the element renders in neutral grays via the CSS Color Level 4 saturate matrix, distinct from the grayscale() formula. At saturate(3) or higher, colors are pushed into garish neon territory: red security badges become hot magenta-pink, green approval badges shift to acid lime-yellow, and warning amber becomes a searing neon orange that shares no visual association with the standard amber caution color. Both directions — desaturation and extreme over-saturation — destroy the color-coded risk semantics in MCP server consent dialogs while DOM text, ARIA attributes, and contrast ratios remain completely unchanged.

The saturate matrix — how it differs from grayscale

CSS filter: saturate(N) applies a 5×5 color matrix derived from the CSS Filter Effects specification. The saturate matrix is parameterized by a saturation factor S where S=1 leaves colors unchanged, S=0 produces full desaturation, and S>1 amplifies saturation. The matrix coefficients for S=0 are:

/* CSS saturate(0) matrix — W3C Filter Effects spec */
R' = 0.2126·R + 0.7152·G + 0.0722·B
G' = 0.2126·R + 0.7152·G + 0.0722·B
B' = 0.2126·R + 0.7152·G + 0.0722·B

/* CSS grayscale(1) matrix — same formula (both use ITU-R BT.709 coefficients) */
/* The numeric output is identical at S=0/grayscale=1 for CSS Color Level 4 compliant browsers */
/* Key difference: the *CSS filter function names* differ — a scanner checking for
   'grayscale' keyword will NOT find a saturate(0) attack; the attack uses a different
   filter function while producing the same visual result. */

The key security implication is that saturate(0) and grayscale(1) are visually equivalent in modern browsers — both produce the same luminance-based gray — but they appear as different strings in getComputedStyle().filter. A scanner that checks only for grayscale() will find no match when the attack uses saturate(0). Both functions must be checked independently.

For over-saturation (S>1), the matrix amplifies the chroma distance from neutral gray. Colors near the neutral axis (such as a muted warning badge) are pushed further away; colors already saturated (like the bright red #f44336 or bright green #4caf50 used in security UIs) are pushed outside the sRGB gamut and clamped — producing colors that are maximum-intensity in one or two channels but shifted toward unrecognizable hues.

Attack 1: filter: saturate(0) — full desaturation via alternate matrix

Applying saturate(0) to consent UI elements produces the same gray tones as grayscale(1) but with a different filter function signature. This is a deliberate evasion technique: a scanner that has been written to catch the grayscale attack explicitly looks for the string grayscale in the filter value. saturate(0) produces no match while achieving the identical visual effect.

The color output at saturate(0):

/* Desaturation attack using saturate(0) — visually identical to grayscale(1)
   but uses a different filter function name to evade grayscale-specific scanners */
.risk-badge,
.permission-level-indicator {
  filter: saturate(0);
  /* Scanner checking for 'grayscale': MISS
     Scanner checking for 'saturate':   HIT (correct)
     Visual output: identical to grayscale(1) */
}

/* Also valid as percentage form: */
.risk-badge {
  filter: saturate(0%); /* normalized to 0 — same result */
}
/* Detection — must check both grayscale AND saturate */
function parseSaturateValue(filter) {
  if (!filter || filter === 'none') return 1; // 1 = unchanged
  const match = filter.match(/saturate\((\d+(?:\.\d+)?)(%?)\)/);
  if (!match) return 1;
  const raw = parseFloat(match[1]);
  const norm = match[2] === '%' ? raw / 100 : raw;
  return norm;
}

function checkSaturateDesaturation(element) {
  const filter = getComputedStyle(element).filter;
  const sat = parseSaturateValue(filter);

  // Low saturation: color semantics compromised
  if (sat < 0.3) {
    return {
      flagged: true,
      type: 'desaturation',
      value: sat,
      severity: sat === 0 ? 'HIGH' : 'MEDIUM',
      message: `saturate(${sat}) — color semantics stripped (${Math.round((1-sat)*100)}% desaturation)`
    };
  }
  return null;
}

// IMPORTANT: check both filter functions to detect the evasion variant
function checkAllDesaturationFilters(element) {
  const filter = getComputedStyle(element).filter;
  return [
    parseGrayscaleValue(filter) > 0.3 ? 'grayscale' : null,
    parseSaturateValue(filter) < 0.3 ? 'saturate' : null
  ].filter(Boolean);
}

Scanner evasion by function substitution: An MCP server that has been informed that SkillAudit checks for grayscale() can switch to saturate(0) for identical visual effect. Comprehensive desaturation detection requires checking both filter functions — and also sepia(1), which produces a third distinct desaturation signature.

Attack 2: filter: saturate(4) — over-saturation to unrecognizable neon colors

Over-saturation is an underappreciated attack vector. At high saturation values, security colors that users recognize — the specific shade of red #f44336 that means "danger", the specific shade of green #4caf50 that means "safe" — are transformed into colors outside normal experience. The recognition failure is not that the colors become invisible; it is that the colors no longer match the user's learned associations.

When saturate(4) is applied to a red badge, the red channel (already near 255) saturates to maximum, the green channel drops toward zero, and the blue channel is affected by the matrix coefficients — the result shifts red toward a bright magenta-crimson. The specific amber yellow #fbbf24 used for warning states shifts toward a burning neon orange-red that reads as another type of danger color, indistinguishable from the high-risk color. The green badge shifts toward acid lime-yellow — a color with no learned "safe/approved" association. Three distinct security levels (red/yellow/green) become three different neon shades, each visually alarming but with their differentiation and learned meanings destroyed.

/* Over-saturation attack — colors pushed into unrecognizable neon territory */
.risk-badge {
  filter: saturate(4);
  /* Color transformation at saturate(4):
     Red    #f44336 → neon magenta-crimson (~#ff0050 region after clamping)
     Yellow #fbbf24 → burning neon orange (~#ff8800 region)
     Green  #4caf50 → acid lime-yellow  (~#00ff20 region)

     User expectation: red=danger, yellow=caution, green=safe
     Post-attack: three garish neon colors; the learned semantic mapping fails
     because none of these match the standard UI colors users recognize */
}

/* Partial over-saturation — more subtle, may appear as "vivid theme" */
.permission-dialog {
  filter: saturate(2.5);
  /* At 2.5x: colors are noticeably unnatural but may be dismissed as styling;
     however the green→lime shift is sufficient to break green=safe recognition */
}
/* Detection — flag extreme saturation values */
function checkSaturateOverSaturation(element) {
  const filter = getComputedStyle(element).filter;
  const sat = parseSaturateValue(filter);

  // Over-saturation: security color associations distorted
  if (sat > 2.0) {
    return {
      flagged: true,
      type: 'over_saturation',
      value: sat,
      severity: sat >= 3 ? 'HIGH' : 'MEDIUM',
      message: `saturate(${sat}) — ${sat}x saturation destroys recognized security color associations`
    };
  }
  return null;
}

// Complete check: both under and over saturation
function checkSaturateRange(element) {
  const sat = parseSaturateValue(getComputedStyle(element).filter);
  if (sat < 0.3) return { type: 'desaturation', severity: 'HIGH' };
  if (sat > 2.0) return { type: 'over_saturation', severity: sat >= 3 ? 'HIGH' : 'MEDIUM' };
  return null;
}

Over-saturation masquerades as stylistic choice: A consent dialog with over-saturated colors might appear to be using a "vivid" or "high-contrast" theme. Unlike grayscale(1) which is obviously unusual, saturate(2.5) could pass casual review as intentional branding. Detection must be based on the numeric threshold, not visual inspection.

Attack 3: saturate(0.05) — near-zero saturation evades threshold scanners at ≤0.1

A scanner that checks only for the exact value saturate(0) misses all near-zero values. At saturate(0.05), the color retains 5% of its original saturation — effectively fully desaturated for practical purposes (5% chroma is below human discrimination thresholds in typical office lighting). Yet it does not match a string equality check for saturate(0). More subtly, a scanner using a threshold of 0.1 (10%) would also miss 0.05, 0.08, and 0.09 — each of which produces near-complete desaturation.

/* Near-zero saturation — evades exact-value and low-threshold scanners */
.risk-badge {
  filter: saturate(0.05); /* 5% color retention — effectively desaturated */
  /* Scanner checking saturate === 0:    MISS (not exactly zero)
     Scanner with threshold sat < 0.1:  MISS (0.05 < 0.1 but threshold is exclusive)
     Scanner with threshold sat < 0.3:  HIT (correct — 5% chroma is functionally gray) */
}

/* Also used as a float precision obfuscation */
.risk-badge {
  filter: saturate(.05); /* CSS allows omitted leading zero — same value */
}

/* Percentage form */
.risk-badge {
  filter: saturate(5%); /* identical to 0.05 */
}

Attack 4: Compound saturate in multi-function filter chain

CSS filter accepts a space-separated list of filter functions applied in order. A compound chain like filter: blur(0.5px) saturate(0.1) buries the saturation attack inside what might look like a harmless soft-focus effect. A scanner parsing the filter string for known dangerous functions must handle the multi-function format rather than checking for a single value. Regex patterns that look for only the saturate function in isolation will fail when it appears mid-chain.

/* Compound filter chain — saturate buried alongside other functions */
.permission-modal {
  filter: blur(0.3px) saturate(0.08) brightness(1.05);
  /* The blur(0.3px) is minimal — barely visible soft focus that might appear intentional
     The saturate(0.08) is the attack — 8% color retention, near-complete desaturation
     The brightness(1.05) is cosmetic — keeps overall appearance "normal"

     A naive scanner extracting filter === 'saturate(...)' fails on compound chains.
     Correct parsing: split the filter string into individual function tokens first. */
}

/* Detection — parse compound filter chains correctly */
function extractFilterFunctions(filterString) {
  if (!filterString || filterString === 'none') return [];
  // Match all filter function tokens: name(value)
  const pattern = /(\w+)\(([^)]*)\)/g;
  const functions = [];
  let match;
  while ((match = pattern.exec(filterString)) !== null) {
    functions.push({ name: match[1], value: match[2] });
  }
  return functions;
}

function auditFilterChain(element) {
  const filter = getComputedStyle(element).filter;
  const fns = extractFilterFunctions(filter);
  const findings = [];

  for (const fn of fns) {
    if (fn.name === 'saturate') {
      const raw = parseFloat(fn.value);
      const norm = fn.value.includes('%') ? raw / 100 : raw;
      if (norm < 0.3) findings.push({ fn: 'saturate', norm, issue: 'desaturation' });
      if (norm > 2.0) findings.push({ fn: 'saturate', norm, issue: 'over_saturation' });
    }
    // Also check other filter functions for compound attacks
    if (fn.name === 'grayscale') { /* ... */ }
    if (fn.name === 'sepia') { /* ... */ }
  }

  return findings;
}

Tokenize before checking: CSS filter values are space-separated function lists, not a single value. Parse the string into individual function tokens using a regex that matches name(args) patterns. Only after tokenization can you reliably check each function's argument regardless of what other functions appear in the chain.

Summary

Attack saturate value Visual effect Evasion note
Full desaturation saturate(0) Same gray output as grayscale(1) — color semantics stripped Evades grayscale-only scanners; different function name, identical visual result
Near-zero desaturation saturate(0.05) 5% chroma retained — effectively desaturated below human discrimination threshold Evades exact-value saturate(0) checks and low-threshold scanners
Extreme over-saturation saturate(4) Red→neon magenta, green→acid lime, yellow→neon orange — all learned associations broken Passes contrast audits; may appear as "vivid styling" to casual reviewers
Compound chain blur(0.3px) saturate(0.08) Desaturation buried in multi-function chain Evades single-function string matching; requires chain tokenization to detect

SkillAudit findings for CSS filter: saturate()

HIGH filter:saturate(0) on consent UI elements produces full desaturation — functionally identical to grayscale(1) but with a different filter function name designed to evade grayscale-specific scanners. All color-coded risk semantics (red=danger, green=safe, yellow=caution) are stripped while text contrast and WCAG compliance remain unchanged.
HIGH filter:saturate(4) or higher on risk badges or action buttons pushes security colors into neon territory where learned associations no longer apply — red HIGH RISK badges shift toward magenta-crimson, green LOW RISK badges shift toward acid lime, and amber MEDIUM RISK badges shift toward neon orange. Three distinct severity levels become three differently unrecognizable neon colors.
MEDIUM filter:saturate(0.05) retains only 5% chroma — effective full desaturation for human perception — while evading saturate(0) exact-value scanners. Correct detection requires numeric threshold evaluation: parse the saturate argument and flag any value below 0.3 (desaturation) or above 2.0 (over-saturation).
MEDIUM Compound filter chains (e.g., filter: blur(0.3px) saturate(0.08)) bury saturation attacks alongside innocuous filter functions. Single-function string matching fails on compound chains. Correct detection requires tokenizing the filter string into individual function tokens before checking arguments.

Defences

Bidirectional saturation threshold check: SkillAudit reads the computed filter property, tokenizes the filter chain, and evaluates any saturate() function argument. Values below 0.3 are flagged as desaturation attacks; values above 2.0 are flagged as over-saturation attacks. The threshold applies to the parsed numeric value after normalizing percentage forms.

Coverage of all desaturation functions: SkillAudit checks grayscale(), saturate(), and sepia() independently, since each produces visually similar desaturation or color-neutralizing effects but has a different string signature. A comprehensive scanner must flag all three — an MCP server can switch between them to evade single-function checks.

Ancestor chain traversal: CSS filter is not an inherited property — a saturate(0) on a dialog ancestor desaturates all child content but does not propagate the computed filter value to child elements. SkillAudit walks the ancestor chain from each consent-critical leaf element to the document root, checking each ancestor's computed filter value.

Chain tokenization: All filter chain parsing tokenizes the filter string into individual function tokens using a /(\w+)\(([^)]*)\)/g regex before checking arguments. This correctly handles multi-function chains like blur(1px) saturate(0.05) brightness(1.1) without false negatives from chain prefix or suffix matching.

Related: CSS filter security overview · CSS filter grayscale security · CSS filter sepia security · CSS filter invert security