Research · CSS Security · MCP Servers
CSS @property Registered Custom Properties and MCP Consent Attacks: initial-value Priority, inherits:false Isolation, Type Constraint Violations, and Load-Order Race Conditions
CSS @property (Houdini registered custom properties, shipping in all major browsers since 2022) fundamentally changes how custom properties resolve. The central danger for consent frameworks: var(--consent-opacity, 1) is no longer a safe default — a registered @property with initial-value: 0 counts as a defined value, so the fallback is never invoked. MCP servers exploit four distinct mechanisms to weaponize @property against consent disclosures.
Contents
- Background: how @property changes custom property resolution
- Attack 1: initial-value beats var() fallback — the silent zero
- Attack 2: inherits:false isolation — breaking :root propagation
- Attack 3: type constraint violations — coercing initial-value via invalid syntax
- Attack 4: load-order race condition — declaring @property before the consent framework
- Compound attack: all four in sequence
- Unified detection: auditing registered properties via CSS Properties API
- Attack summary and severity
Background: how @property changes custom property resolution
Unregistered custom properties (plain --consent-opacity: 1 in a stylesheet) are always inherited and are always treated as undefined at the point of substitution until they resolve to a concrete value. When you write opacity: var(--consent-opacity, 1), the 1 is the guaranteed fallback: if --consent-opacity is not set or inherits nothing, the fallback is used.
CSS Houdini's @property at-rule registers a custom property with a specified syntax type, initial value, and inheritance behavior:
@property --consent-opacity {
syntax: '<number>';
initial-value: 1;
inherits: true;
}
This registration changes two critical behaviors: (1) the property now has a typed initial value that the browser uses when no explicit value is set for the property on the element's inheritance chain, and (2) the inherits flag controls whether the value propagates from parent to child. A registered property with initial-value: 0; inherits: false means every element starts with 0 for that property, regardless of what any ancestor sets. The var() fallback is only used when the custom property itself is invalid at computed value time — a registered property always has a valid computed value (at minimum, the initial-value), so the fallback is permanently disabled.
Browser support: @property is supported in Chrome 85+, Edge 85+, Safari 16.4+, and Firefox 116+. In other words, every major browser engine supports it as of late 2023. MCP server attacks using @property are production-grade threats today.
The CSS Properties and Values API Level 1 (Houdini) specification also allows JavaScript-side registration via CSS.registerProperty(), which gives MCP server scripts another injection vector. Both the CSS @property at-rule and the JS API are attack surfaces discussed in this post. For the complementary angle on how these registrations are exploited via cascade layers, see MCP server CSS @property security. For the specific initial-value and inherits attack patterns in concise form, see MCP server CSS registered custom property security.
Attack 1: initial-value beats var() fallback — the silent zero
High severityThe consent framework uses a CSS custom property to control element opacity, relying on the fallback in case the property is unset:
/* Consent framework — trusts the var() fallback */
.consent-panel {
opacity: var(--consent-opacity, 1);
/* Developer assumes: if --consent-opacity is not set, fallback to 1 (fully visible) */
}
An MCP server registers the property with a hiding initial-value before the consent framework's stylesheet loads:
/* MCP server @property declaration (appears early in <head>) */
@property --consent-opacity {
syntax: '<number>';
initial-value: 0; /* <-- The attack: hiding initial-value */
inherits: true;
}
/* No explicit setting of --consent-opacity anywhere.
The consent framework relies on var(--consent-opacity, 1).
But the property is now "registered" with initial-value: 0.
Because a registered property always has a valid computed value
(the initial-value), the var() substitution sees the property as
"defined" with value 0 — the fallback '1' is never reached.
Result: .consent-panel { opacity: 0 } — invisible, zero opacity.
The element exists in the DOM, has dimensions, and passes
IntersectionObserver visibility checks (it occupies layout space).
getComputedStyle(panel).opacity returns "0". */
The attack is invisible in DevTools CSS inspection unless the developer explicitly checks the registered property list. The computed value panel shows opacity: 0, but the source is not a CSS rule — it comes from the initial-value of the registered property. There is no cascade rule to inspect, no overriding selector to identify. The property simply resolves to 0.
Why var() fallback is no longer a safety net
The CSS specification states that the fallback value in var(--prop, fallback) is used when --prop resolves to guaranteed-invalid at computed value time. A registered property with a valid initial-value never reaches guaranteed-invalid — it always has initial-value as its computed value when no explicit assignment applies. This means the widely-taught pattern "use var(--x, safe-default)" provides zero protection against @property attacks. Consent frameworks built on this assumption need to be audited and patched.
Attack 2: inherits:false isolation — breaking :root propagation
High severityA natural defense would be for the consent framework to explicitly set the custom property on :root or body, overriding any hiding initial-value. An unregistered custom property inherits by default, so a :root assignment propagates through the entire DOM. But a registered property with inherits: false breaks this propagation — each element independently uses the initial-value regardless of what any ancestor defines.
/* MCP attack: register with inherits:false */
@property --consent-opacity {
syntax: '<number>';
initial-value: 0;
inherits: false; /* <-- Key: isolation from ancestor values */
}
/* Consent framework attempts to override on :root */
:root {
--consent-opacity: 1; /* Developer sets the "safe" value on root */
}
/* Consent element (child of body, grandchild of :root): */
.consent-panel {
opacity: var(--consent-opacity, 1);
}
/* Resolution with inherits:false:
- :root has --consent-opacity: 1 (explicit assignment, valid)
- body inherits from :root? NO — inherits:false means no inheritance
- .consent-panel is a child of body, not :root (or body)
- .consent-panel does not inherit the :root value
- .consent-panel gets initial-value: 0 instead
- opacity: var(--consent-opacity, 1) → var() substitutes 0
- Result: .consent-panel { opacity: 0 }
To defend against this, the framework would need to set
--consent-opacity: 1 directly on .consent-panel — on every
consent element individually. A :root or body assignment is
insufficient when the property has inherits:false. */
This attack is particularly insidious when combined with a complex consent framework that sets up "theming tokens" on :root. The framework correctly assigns all theme values at the root level — perfectly reasonable for unregistered custom properties — but the MCP's registration with inherits: false silently breaks the entire theming cascade for the consent element while leaving all other themed elements (nav, footer, cards) working correctly. The consent element is the only element that fails, exactly as designed by the attacker.
Attack 3: type constraint violations — coercing initial-value via invalid syntax
High severityCSS @property enforces type checking: if the registered syntax is '<number>' and an explicit assignment provides a non-number value, the browser treats the assignment as invalid and falls back to the registered initial-value. An MCP server exploits this by registering a property with an incompatible syntax for what the consent framework assigns:
/* Consent framework uses the property for a boolean-like flag */
:root {
--consent-visible: active; /* string value for visibility flag */
}
.consent-panel {
/* Developer uses the string value in a custom CSS if() or checks it in JS */
display: var(--consent-display, block);
}
/* Legitimate design: --consent-visible: 'active' signals the panel should show */
/* MCP attack: register --consent-visible with numeric syntax */
@property --consent-visible {
syntax: '<integer>'; /* requires an integer — 'active' is not a valid integer */
initial-value: 0;
inherits: true;
}
/* Resolution:
:root { --consent-visible: active }
BUT the property's registered syntax is '<integer>'.
'active' is not a valid <integer> value.
The browser discards the invalid assignment.
--consent-visible resolves to initial-value: 0 on all elements.
If the consent framework has any CSS logic that reads --consent-visible
(e.g. via CSS if(), animation-timeline, or JavaScript getPropertyValue),
it sees 0 instead of 'active'.
Combined with: */
@property --consent-display {
syntax: 'none | block';
initial-value: none; /* hiding initial-value */
inherits: false;
}
/* Any assignment of --consent-display that isn't exactly 'none' or 'block'
(e.g., 'flex', 'grid', 'inline-block') is treated as invalid by the browser
and initial-value 'none' applies instead. */
Type constraint violations are especially effective against consent frameworks that use loosely-typed custom properties. If the framework sets --consent-state: 'visible' and the MCP registers syntax: '<number>', the string value is invalid for the registered type — the browser silently discards it. No error is thrown. No console warning is shown. The computed value is simply 0 (initial-value), and the consent element renders invisible.
For a deeper exploration of this specific attack surface, see MCP server custom properties security and the CSS initial-value attack patterns page.
Attack 4: load-order race condition — declaring @property before the consent framework
High severityWhen two @property declarations for the same custom property name appear in the same document, the first one wins — later declarations for the same property name are ignored. This is different from ordinary CSS cascade, where later rules typically override earlier ones. The @property registration is effectively a one-time operation per property name per document; subsequent registrations for the same name are silently dropped.
/* MCP server injects this @property declaration in a stylesheet loaded
before the consent framework. Two common injection points:
1. Via an MCP server's injected <style> tag inserted before
the consent framework's <link> or <script> tags.
2. Via a stylesheet from an MCP-controlled domain that the host
page's Content-Security-Policy allows (e.g., a shared component library). */
/* MCP's early-loading stylesheet: */
@property --consent-opacity {
syntax: '<number>';
initial-value: 0; /* hiding initial-value registered first */
inherits: true;
}
/* Later: consent framework's stylesheet loads: */
@property --consent-opacity {
syntax: '<number>';
initial-value: 1; /* framework's safe initial-value — IGNORED */
inherits: true;
}
/* The browser ignores the second @property for --consent-opacity.
The MCP's registration (initial-value:0) remains active.
The framework's attempt to register initial-value:1 has no effect.
Even if the framework calls CSS.registerProperty() in JavaScript: */
// Framework JS (loaded after MCP's stylesheet):
try {
CSS.registerProperty({
name: '--consent-opacity',
syntax: '<number>',
initialValue: '1',
inherits: true,
});
} catch (e) {
// DOMException: 'Failed to execute registerProperty on CSS:
// The name provided has already been registered.'
// The framework catches the exception but the hiding initial-value
// is already in place. The exception handling doesn't undo the MCP's registration.
console.error('Property already registered:', e);
}
/* The DOMException is thrown but the consent framework has no recovery path.
The --consent-opacity property is permanently locked to initial-value:0
for the lifetime of the document. */
Load-order attacks work even when the MCP server has no CSS injection capability, only script injection. A CSS.registerProperty() call in a script that runs early in document parsing (e.g., via a <script> tag inserted before the consent framework's script) achieves the same result. The attack requires only that the MCP's code runs before the consent framework's @property or CSS.registerProperty() declarations.
No recovery path: Once a CSS @property registration is in place, there is no browser API to unregister or override it. The document must be reloaded. This makes the load-order attack particularly severe — the consent framework cannot programmatically undo the MCP's registration even after detecting it.
Compound attack: all four in sequence
An advanced MCP server may combine all four attack surfaces to create a defense-in-depth suppression chain that defeats multiple layers of consent framework protection:
/* Stage 1: Race condition — inject before consent framework */
/* (MCP's stylesheet loaded first via early <style> injection) */
@property --consent-opacity {
syntax: '<number>';
initial-value: 0; /* Stage 1: wins the registration race */
inherits: false; /* Stage 2: inherits:false breaks :root override */
}
/* Stage 3: Type violation backup — register display property too */
@property --consent-display {
syntax: 'none | block';
initial-value: none;
inherits: false;
}
/* Result:
- Consent framework's @property declaration is silently dropped (Stage 1)
- :root { --consent-opacity: 1 } doesn't propagate (Stage 2)
- Framework assigns --consent-display: flex → invalid for 'none|block' → none (Stage 3)
- Framework uses var(--consent-opacity, 1) → resolves to initial-value 0 (Stage 4)
The consent panel is invisible. Four independent attack paths all lead
to the same result. Removing any one of them leaves the others active.
The consent framework needs to detect and counter all four simultaneously. */
Unified detection: auditing registered properties via CSS Properties API
Detecting @property attacks requires reading the registered property list — something only possible via document.styleSheets (for CSS-declared) or CSS.supports() combined with registration side-effects. The CSS Properties and Values API does not expose a getRegisteredProperties() method, so detection requires iterating stylesheet rules.
/**
* Audits all @property declarations for suspicious initial-values
* that could hide consent elements.
*
* Checks:
* 1. Properties with hiding initial-values (opacity:0, display:none, visibility:hidden, etc.)
* 2. Properties registered with inherits:false that shadow :root-set values
* 3. Properties registered with syntax types that invalidate likely framework values
*/
function auditRegisteredProperties(consentPropertyNames = [
'--consent-opacity', '--consent-display', '--consent-visibility',
'--consent-height', '--consent-font-size', '--consent-color',
]) {
const findings = [];
// Iterate all stylesheets for @property rules
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.constructor.name !== 'CSSPropertyRule') continue;
const name = rule.name; // e.g. '--consent-opacity'
const initialValue = rule.initialValue; // e.g. '0'
const inherits = rule.inherits; // true | false
const syntax = rule.syntax; // e.g. '<number>'
// Check 1: hiding initial-values on consent-related properties
const hidingValues = ['0', 'none', 'hidden', 'transparent', '-9999px', '-9999em'];
const isConsentProp = consentPropertyNames.includes(name)
|| name.includes('consent') || name.includes('disclosure')
|| name.includes('policy') || name.includes('notice');
if (isConsentProp && hidingValues.includes(initialValue.trim())) {
findings.push({
type: 'hiding-initial-value',
severity: 'HIGH',
property: name,
initialValue,
inherits,
message: `@property ${name} has initial-value:${initialValue} — var() fallback will not be used`,
});
}
// Check 2: inherits:false on consent properties (breaks :root override)
if (isConsentProp && inherits === false) {
findings.push({
type: 'inherits-false-isolation',
severity: 'HIGH',
property: name,
initialValue,
inherits,
message: `@property ${name} has inherits:false — :root and body assignments will not propagate to consent elements`,
});
}
// Check 3: syntax mismatch — check if :root assigns a value incompatible with syntax
if (isConsentProp) {
const rootValue = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const computedOnEl = consentPropertyNames.includes(name)
? (() => {
const el = document.querySelector('[class*="consent"], [id*="consent"]');
return el ? getComputedStyle(el).getPropertyValue(name).trim() : null;
})()
: null;
if (rootValue && computedOnEl !== null && rootValue !== computedOnEl) {
findings.push({
type: 'type-violation-or-inherits-isolation',
severity: 'HIGH',
property: name,
rootValue,
computedOnConsent: computedOnEl,
message: `@property ${name}: :root sets '${rootValue}' but consent element computes '${computedOnEl}' — type violation or inherits:false causing discrepancy`,
});
}
}
}
} catch (e) {
// Cross-origin stylesheet — cannot inspect rules
}
}
// Check 4: detect registration conflicts by attempting CSS.registerProperty()
for (const propName of consentPropertyNames) {
try {
CSS.registerProperty({ name: propName, syntax: '*', initialValue: '', inherits: true });
// If this succeeds, the property was NOT previously registered — safe
// (though we just registered it ourselves, which we should track)
} catch (e) {
if (e.name === 'InvalidModificationError' || e.message.includes('already been registered')) {
// Property is already registered — determine if MCP's or framework's
// (framework should register its own properties; unexpected registrations are suspicious)
const isKnownFrameworkRegistration = false; // framework tracks its own @property list
if (!isKnownFrameworkRegistration) {
findings.push({
type: 'unexpected-registration',
severity: 'MEDIUM',
property: propName,
message: `CSS.registerProperty(${propName}) throws — property already registered by an earlier source`,
});
}
}
}
}
if (findings.length === 0) {
console.log('CSS @property audit: no suspicious registered properties found');
} else {
console.error('CSS @property audit findings:', findings);
}
return findings;
}
// Run on DOMContentLoaded (before consent framework scripts execute):
document.addEventListener('DOMContentLoaded', () => {
const findings = auditRegisteredProperties();
if (findings.some(f => f.severity === 'HIGH')) {
// Block consent UI initialization — the properties cannot be trusted
throw new Error('CSS @property security audit failed — consent display properties are compromised');
}
});
The auditor above covers all four attack surfaces. Run it early — ideally as the first script in <head>, before any MCP-controlled stylesheets load. The race condition attack (Attack 4) means that detection must happen before the consent framework tries to register its own properties. See the MCP server security checklist for the full pre-initialization audit sequence.
Attack summary and severity
| Attack | Mechanism | Effect on consent | Severity | Detection |
|---|---|---|---|---|
| initial-value beats var() fallback | @property { initial-value: 0 } on consent property |
var() fallback never invoked; property resolves to hiding initial-value | High | Iterate CSSPropertyRules for hiding initialValue |
| inherits:false isolation | @property { inherits: false } blocks :root propagation |
Framework's :root assignment doesn't reach consent element | High | Check rule.inherits === false for consent properties |
| Type constraint violation | Register incompatible syntax; framework assignment invalid | Browser discards framework's value; initial-value:0 applies | High | Compare :root computed value to consent element computed value |
| Load-order race condition | First @property wins; second declaration (framework's) silently dropped | Framework's safe initial-value never registered; DOMException on retry | High | Try CSS.registerProperty() for expected framework properties; catch InvalidModificationError |
Remediation: Consent frameworks should (1) avoid relying on var() fallback for safety-critical visibility properties, (2) use CSS containment or shadow DOM to isolate consent element styles from the main document's registered properties, (3) run CSS.registerProperty() for all consent-critical properties as the very first script tag in <head> before any MCP-controlled content loads, and (4) use getComputedStyle(el).opacity rather than trusting that the CSS rules look correct. A consent element with opacity: var(--consent-opacity, 1) requires inspecting the actual computed value — not the rule text — to verify it rendered visibly.