MCP server CSS custom property var() consent security
CSS custom properties (variables) store arbitrary values under author-defined names and substitute them at use sites via var(). Consent-text erasure attacks use custom properties to decouple the erasure value from the erasure property declaration, defeating static auditors that search for literal property values like color: transparent, opacity: 0, or visibility: hidden. The stylesheet shows color: var(--c); the auditor must resolve the variable reference to discover the actual rendered value. Four SA-CSS-VAR patterns cover the primary custom-property obfuscation techniques.
CSS custom properties (formally CSS Custom Properties for Cascading Variables) are declared with a --name: value syntax and consumed with var(--name). They inherit like standard properties and cascade normally. The browser resolves var() references during computed style calculation, which means getComputedStyle(el).color will return the fully resolved color value — rgba(0,0,0,0) for transparent — rather than the var(--c) string. This is critical: getComputedStyle-based detection is not defeated by custom property indirection. The vulnerability lies in static stylesheet analysis tools that inspect CSS rules without resolving variables, and in auditors that only check the declaration site rather than the computed value.
SA-CSS-VAR-001: color: var(--c) — transparent color via custom property
An attacker declares --c: transparent on a container or root element, then applies color: var(--c) to the consent text element. Static CSS rule inspection sees color: var(--c) — a complex token, not a color value — and may not evaluate the variable reference. The computed style correctly returns rgba(0,0,0,0), but auditors that pattern-match for color: transparent or color: rgba(0,0,0,0) in the stylesheet text will miss the attack.
/* Attack: color erasure via custom property */
:root {
--c: transparent; /* or: --c: rgba(0,0,0,0); --c: white; --c: #fff; */
}
/* OR declared on a parent container: */
.consent-wrapper {
--c: transparent;
}
.consent-text {
color: var(--c); /* stylesheet shows: color: var(--c) — not 'transparent' */
}
/* Obfuscated variable name: */
:root {
--theme-text-primary: transparent; /* harmless-looking name */
--consent-color: transparent; /* obvious if found, but auditors may skip custom prop resolution */
--a: transparent; /* single-letter name to minimize grep surface */
}
/* How static analysis is defeated:
grep/search for 'color: transparent' → no match
grep/search for 'color: rgba(0,0,0,0)' → no match
grep/search for 'color:white' (where page bg is white) → no match
→ The erasure value is in the custom property declaration, not the color rule
Correct detection (BOTH should be performed):
1. getComputedStyle(consentTextEl).color
→ 'rgba(0, 0, 0, 0)' → CRITICAL (computed color is transparent)
2. Stylesheet analysis: find color: var(--X) declarations on consent elements
→ look up --X value from getPropertyValue('--X')
→ evaluate if resolved value is an erasure color */
getComputedStyle resolves var() — use it: Static analysis of CSS rule text is defeated by custom property indirection. The authoritative check is always getComputedStyle(consentTextEl).color, which returns the fully resolved color value. An audit that reads computed styles rather than rule text correctly catches all var()-based color attacks.
color: var(--c) with --c: transparent: erasure value in custom property defeats static grep/text analysis; getComputedStyle correctly resolves to rgba(0,0,0,0). Detection: check getComputedStyle.color → CRITICAL if transparent-equivalent.SA-CSS-VAR-002: opacity: var(--op) — opacity:0 via custom property
opacity: var(--op) with --op: 0 applies full transparency to the consent text element. Unlike the color attack, which affects only text color, opacity: 0 affects the entire element including borders, backgrounds, and any child elements. The attack is particularly effective against consent dialogs that use colored borders or backgrounds as visual indicators: opacity:0 erases everything simultaneously. getComputedStyle(el).opacity correctly returns '0'.
/* Attack: opacity:0 via custom property */
:root {
--op: 0;
}
.consent-text {
opacity: var(--op);
/* getComputedStyle(consentTextEl).opacity → '0' — still detectable!
But static rule scanning for 'opacity: 0' misses this */
}
/* Compound: var() + inherited value */
.consent-container {
--op: 0;
opacity: var(--op); /* applies to container + all children including consent text */
}
/* The consent-text element may not have its own opacity property;
the effective opacity is 0 via inheritance of the parent's opacity context
(note: opacity is NOT inherited via CSS cascade — it's composited —
but an ancestor with opacity:0 makes all descendants invisible) */
/* Detection:
getComputedStyle(consentTextEl).opacity → '0' → CRITICAL
Also check ancestor chain for opacity:0:
let el = consentTextEl;
while (el) {
if (getComputedStyle(el).opacity === '0') → CRITICAL;
el = el.parentElement;
}
This catches cases where erasure is on ancestor, not on consent element itself. */
Opacity affects the entire stacking context: Unlike color: transparent which affects only text color, opacity: 0 makes the entire element and its subtree invisible. An ancestor with opacity: var(--op) where --op: 0 erases all descendant consent UI elements simultaneously.
opacity: var(--op) with --op: 0: full element transparency via custom property; defeats literal 'opacity: 0' rule scanning. Detection: getComputedStyle.opacity === '0' or ancestor chain check → CRITICAL.SA-CSS-VAR-003: visibility: var(--vis) — visibility:hidden via custom property
visibility: var(--vis) with --vis: hidden hides the consent text while preserving layout space. visibility: hidden elements occupy their layout box (unlike display: none) but render no pixels and are removed from the accessibility tree. The getComputedStyle(el).visibility check returns 'hidden' correctly. Static auditors searching for visibility: hidden in CSS rules miss the var() indirection.
/* Attack: visibility:hidden via custom property */
:root {
--vis: hidden;
}
.consent-text {
visibility: var(--vis);
/* Element occupies layout space but renders invisible.
Accessibility tree: element is present but screen readers skip hidden elements. */
}
/* Compound: visibility:hidden with pointer-events:none */
:root {
--vis: hidden;
--pe: none;
}
.consent-text {
visibility: var(--vis);
pointer-events: var(--pe);
/* Both visual and interaction presence removed via custom properties */
}
/* Detection:
getComputedStyle(consentTextEl).visibility
→ 'hidden' → CRITICAL
Note on visibility inheritance:
visibility:hidden IS inherited by CSS cascade — child elements are also hidden.
However: a child with explicit 'visibility: visible' overrides the inherited hidden.
This means an attacker can hide a parent and make child elements explicitly visible
to show a misleading subset of the consent UI while hiding the main consent text:
.consent-wrapper { visibility: var(--vis); }
.consent-accept-btn { visibility: visible; } /* accept button remains visible!
→ Only the consent text is hidden; the accept button is shown */
Selective visibility: hide text, show button: CSS visibility inheritance allows hiding a container while selectively making children visible with visibility: visible. An attacker can hide the consent text via visibility: var(--vis) on the wrapper while keeping the “Accept” button visible with an explicit visibility: visible override — presenting an accept button with no readable consent text.
visibility: var(--vis) with --vis: hidden: consent text hidden while accept button may remain visible via visibility override. Detection: getComputedStyle.visibility === 'hidden'; also check selective child visibility pattern → CRITICAL.SA-CSS-VAR-004: Unregistered custom property inheritance across DOM tree
CSS custom properties inherit through the DOM tree by default. An unregistered custom property (one not declared via @property with inherits: false) cascades from ancestor to descendant. This allows an attacker to set the erasure value on a distant ancestor element — such as the document root (:root or html) or a layout container far up the DOM tree — and consume it on the consent text via var(). The erasure declaration appears nowhere near the consent element in the stylesheet.
/* Attack: erasure value on distant ancestor, consumed on consent text */
/* Declared far from consent context: */
body {
--content-color: transparent; /* looks like a theming variable */
}
/* Consumed on consent text: */
.modal-dialog .consent-text {
color: var(--content-color); /* inherits --content-color from body */
}
/* Even more distant: */
:root {
--t: transparent;
}
/* Any element in the document can consume --t via var(--t) */
/* Static analysis challenge:
Finding this attack requires:
1. Identifying var() references in consent-element rules
2. Traversing the CSS cascade to find where --t is declared
3. Determining the resolved value at the consent element's scope
This requires full cascade simulation, not just scanning the rule for the element.
Correct detection:
// Get all custom property references used in erasure-relevant declarations:
const style = getComputedStyle(consentTextEl);
// Check computed values (they resolve all var() at computed-style time):
['color', 'opacity', 'visibility', 'display'].forEach(prop => {
const val = style.getPropertyValue(prop);
if (isErasureValue(val)) → CRITICAL;
});
// To find the SOURCE of the var() for remediation guidance:
const rawRule = getMatchingCSSRule(consentTextEl, 'color');
// If rule.style.color includes 'var(--', extract custom property name
// Then: getComputedStyle(consentTextEl).getPropertyValue('--name')
// to find the resolved value at consent element scope */
Computed styles are the ground truth: Across all four SA-CSS-VAR patterns, the common defense is to always check computed property values via getComputedStyle rather than inspecting CSS rule text. The browser's computed style resolution accounts for custom property inheritance, var() substitution, and cascade order — giving the actual rendered value regardless of how many levels of indirection are in the stylesheet.
var() on consent text; no erasure rule at consent element level; requires full cascade traversal to find source. Detection: computed style check on consent element covers this transparently → HIGH (source tracing required for remediation).Summary of SA-CSS-VAR findings
| ID | Severity | Pattern | Static analysis bypass | Detection |
|---|---|---|---|---|
| SA-CSS-VAR-001 | CRITICAL | color: var(--c) with --c: transparent | grep for 'color: transparent' misses var() indirection | getComputedStyle.color → rgba(0,0,0,0) |
| SA-CSS-VAR-002 | CRITICAL | opacity: var(--op) with --op: 0 | grep for 'opacity: 0' misses var() indirection | getComputedStyle.opacity === '0'; ancestor chain check |
| SA-CSS-VAR-003 | CRITICAL | visibility: var(--vis) with --vis: hidden | grep for 'visibility: hidden' misses var(); selective child visibility pattern | getComputedStyle.visibility === 'hidden'; check child visibility overrides |
| SA-CSS-VAR-004 | HIGH | var() consuming value inherited from distant ancestor | No erasure rule at consent element; value set far up DOM tree | Computed style check catches this; source requires cascade traversal |