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
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 ID | Custom property mechanism | Key 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-002 | JS setProperty('--consent-color','#fff') at mousedown | style.setProperty proxy detects mutation during install interaction |
| SA-CSS-CPV-003 | Chained variable: var(--text-muted) → var(--brand-secondary) → #fafafa | getComputedStyle resolves all levels; contrast check on computed value |
| SA-CSS-CPV-004 | Ancestor subtree override: .install-section sets near-white --consent-color | getComputedStyle at consent element's DOM position (not :root value) |