MCP Security Reference

MCP server CSS custom property inheritance consent security

CSS custom properties (also called CSS variables) inherit down the DOM tree by default. A custom property defined on :root — such as --consent-color: #ffffff — is available to every element in the document. When consent text uses color: var(--consent-color), its computed color is white. The stylesheet contains no direct color: white declaration on the consent element; only a variable reference. Static CSS auditors that do not resolve var() references see only the variable name, not the applied value. JavaScript can mutate the variable at runtime via document.documentElement.style.setProperty('--consent-color', '#ffffff') to switch consent from visible to invisible at install-interaction time.

Attack findings

HIGHSA-CSS-CPV-001 — --consent-color:#ffffff (white) defined on :root; consent text uses color:var(--consent-color); computed color is white on white background — invisible; static audit sees color:var(--consent-color) not color:white; auditors that don't resolve variables report no color issue
HIGHSA-CSS-CPV-002 — JS mutates --consent-color to near-white at mousedown on install button: at page load variable is dark (consent visible); at install interaction JS sets near-white; consent becomes invisible before click fires; static analysis at load time: PASS; behavioral analysis: FAIL
HIGHSA-CSS-CPV-003 — chained variable reference: consent text uses color:var(--text-muted); --text-muted is set to var(--brand-secondary); --brand-secondary is set to #fafafa (near-white) on a white background; three-level chain obscures the final color value; auditors that resolve only one level see color:var(--brand-secondary) — an apparently valid brand color reference
MEDIUMSA-CSS-CPV-004 — ancestor element overrides --consent-color to near-white: :root defines --consent-color as dark (visible); .install-section overrides --consent-color:#f5f5f5 (near-white) for its subtree; consent inside .install-section inherits the overridden near-white value; consent outside .install-section remains dark and visible — misleading single-element check

Background: CSS custom property inheritance and the audit gap

CSS custom properties were designed to be a theming and reuse mechanism — define a color palette on :root, reference it throughout the stylesheet. The inheritance mechanism is intentional: custom properties propagate to all descendants, enabling global theming changes by modifying a single root-level declaration. For consent security, this inheritance creates a vector: an attacker can set a consent text color to an invisible value by defining it at a level far removed from the consent element in the DOM tree. The consent element's stylesheet rule contains only color: var(--consent-color) — which to a code reviewer or static auditor looks like a standard theming reference, not a direct color specification.

Audit gap: Many static CSS analysis tools check the color property declaration value. When the value is var(--consent-color), the tool must resolve the variable by walking the DOM ancestor chain and finding the nearest declaration of --consent-color. Tools that skip this resolution step report the color as "unknown" or skip the check entirely — a false pass. The correct detection approach uses getComputedStyle(el).color in a browser context, which the browser has already resolved.

Attack 1 — :root custom property sets consent color to white (SA-CSS-CPV-001)

The most direct variant defines --consent-color: #ffffff on :root alongside other legitimate theme color definitions. The consent text element's stylesheet rule sets color: var(--consent-color) — a pattern that appears to be a proper theming reference. The background of the consent section is also white (the page default). The computed color of the consent text is white on a white background: contrast ratio 1:1, invisible. The stylesheet contains no direct color: white on the consent element. A code reviewer looking at the consent element's CSS sees only a variable reference; they must trace the variable to its definition to discover the attack.

/* Attack: :root custom property makes consent text white */
:root {
  --primary: #6366f1;
  --accent: #10b981;
  --consent-color: #ffffff;  /* Consent text color — set to white */
  /* Appears alongside legitimate theme colors */
}

.consent-text {
  color: var(--consent-color);  /* Resolves to white */
  /* Static audit: color declaration is 'var(--consent-color)' — not 'white' */
  /* getComputedStyle(): color is 'rgb(255,255,255)' — white on white background */
}

/* Detection: use getComputedStyle, not the stylesheet declaration */
function checkConsentColor(el) {
  const cs = getComputedStyle(el);
  const color = cs.color;       // Browser has resolved var() — correct computed value
  const bg = cs.backgroundColor;
  // Compute contrast ratio between color and background
  const contrast = computeContrastRatio(color, bg);
  if (contrast < 4.5) {
    return { vuln: 'SA-CSS-CPV-001',
             detail: `consent color contrast ${contrast.toFixed(1)}:1 — below WCAG AA` };
  }
  return null;
}

SA-CSS-CPV-001 (High). The reliable detection method is getComputedStyle(el).color — the browser resolves all var() references and returns the final computed value. Do not check the stylesheet declaration string; check the computed style. Then compute contrast ratio against the effective background. SkillAudit uses computed styles, not raw stylesheet parsing, for all color checks.

Attack 2 — JS mutates custom property at install interaction time (SA-CSS-CPV-002)

JavaScript can set, override, or remove custom properties on any element via el.style.setProperty('--consent-color', '#ffffff') or document.documentElement.style.setProperty('--consent-color', '#ffffff'). When this call is made in a mousedown event listener on the install button, it fires before the click event. The sequence is: page loads with consent visible (variable set to dark color) → user reads consent → user moves mouse toward install button → mousedown fires → JS sets --consent-color to near-white → consent becomes invisible → click fires → install proceeds. The user saw the consent, moved their mouse toward the button, and at the moment of commitment, the consent disappeared. Static analysis at load time shows a dark consent color; the mutation only occurs at interaction time.

/* Attack: JS mutation of custom property at mousedown */
// Stylesheet
:root { --consent-color: #1a1a1a; }  /* Dark: consent visible at load */
.consent-text { color: var(--consent-color); }

// JavaScript
document.querySelector('#install-btn').addEventListener('mousedown', () => {
  // Fires before click — consent becomes invisible before install commit
  document.documentElement.style.setProperty('--consent-color', '#ffffff');
});

/* Detection: monitor custom property mutations during interaction */
// Proxy style.setProperty on HTMLElement.prototype
const origSet = CSSStyleDeclaration.prototype.setProperty;
CSSStyleDeclaration.prototype.setProperty = function(prop, value, ...args) {
  if (prop.startsWith('--') && value) {
    logPropertyMutation(prop, value, this.ownerNode);
  }
  return origSet.call(this, prop, value, ...args);
};

Attack 3 — chained variable references obscure the final color (SA-CSS-CPV-003)

CSS custom property chains allow var() references to reference other custom properties: color: var(--text-muted) where --text-muted: var(--brand-secondary) and --brand-secondary: #fafafa. Each step in the chain looks like a legitimate theming reference. Code reviewers who see --brand-secondary referenced expect it to be a brand color — not a near-white value. An auditor that resolves one level of indirection (resolves --text-muted to var(--brand-secondary)) still does not see a suspicious color value; they would need to resolve all levels to reach the final #fafafa. The browser's getComputedStyle() resolves all levels — the only correct detection approach for chains of arbitrary depth.

/* Attack: three-level variable chain hides final near-white color */
:root {
  --brand-secondary: #fafafa;           /* Near-white — suspicious if seen directly */
  --text-muted: var(--brand-secondary); /* One level of indirection */
}

.consent-text {
  color: var(--text-muted);  /* Two levels away from the actual color */
  /* Code review: var(--text-muted) looks like a standard muted text style */
  /* Resolution: --text-muted → var(--brand-secondary) → #fafafa */
  /* getComputedStyle().color: rgb(250,250,250) — near-white */
}

/* Detection: use getComputedStyle, always — variable chains resolve automatically */
const computedColor = getComputedStyle(consentEl).color;  // rgb(250,250,250)
// Contrast check catches this regardless of chain depth

Attack 4 — ancestor subtree override of custom property (SA-CSS-CPV-004)

Custom properties can be overridden at any point in the DOM tree. A declaration on a parent element overrides the value for that element's entire subtree. The MCP server defines --consent-color as a dark, legible color in :root. The consent is visible when viewed in isolation. But the consent element lives inside a .install-section container that overrides --consent-color: #f5f5f5 (near-white) for its subtree. Consent inside .install-section inherits the near-white override. The correct audit tests the computed value at the consent element's actual position in the DOM, not the root-level variable declaration.

/* Attack: ancestor override of --consent-color for consent subtree */
:root {
  --consent-color: #1a1a1a;      /* Root default: dark — consent looks fine */
}

.install-section {
  --consent-color: #f5f5f5;      /* Subtree override: near-white */
  background: #ffffff;
  /* .install-section's children inherit near-white --consent-color */
}

.consent-text {
  color: var(--consent-color);
  /* Inside .install-section: resolves to #f5f5f5 — near-white on white */
  /* Outside .install-section: resolves to #1a1a1a — dark on white */
}

/* Detection: check getComputedStyle at the consent element's actual DOM position */
// NOT: getPropertyValue('--consent-color') on :root
// YES: getComputedStyle(consentEl).color — correct scope resolution

SkillAudit detection: SkillAudit uses getComputedStyle(el).color (not raw stylesheet parsing) for all consent color checks, which resolves all var() chains and ancestor overrides automatically. It also monitors style.setProperty() calls during install interaction simulation to catch runtime custom property mutations. Run a free audit →

Detection summary

Attack IDCustom property mechanismKey detection signal
SA-CSS-CPV-001:root --consent-color:white → color:var(--consent-color)getComputedStyle(el).color resolves to near-white; contrast < 4.5:1
SA-CSS-CPV-002JS setProperty('--consent-color','#fff') at mousedownstyle.setProperty proxy detects mutation during install interaction
SA-CSS-CPV-003Chained variable: var(--text-muted) → var(--brand-secondary) → #fafafagetComputedStyle resolves all levels; contrast check on computed value
SA-CSS-CPV-004Ancestor subtree override: .install-section sets near-white --consent-colorgetComputedStyle at consent element's DOM position (not :root value)