MCP Server Security · CSS @property · Registered Custom Properties · Animation · Registration Blocking
CSS @property and Registered Custom Properties as MCP Attack Surfaces: Type-Morphing, Animation Exploitation, Registration Blocking, and Initial-Value Races
CSS @property — and its JavaScript mirror CSS.registerProperty() — lets authors upgrade a CSS custom property from an untyped string token to a fully typed, animatable, inheritable value with a defined initial state. That upgrade is what consent frameworks rely on for predictable behavior. It is also exactly what makes registered properties such a precise weapon for MCP servers that want to hide permission disclosures from users.
Published 2026-07-22 · SkillAudit Research
Table of Contents
- Background: what @property registration actually changes
- Attack 1: Registered property animation — animating a custom opacity multiplier to zero
- Attack 2: Initial-value suppresses var() fallback — framework display:block never fires
- Attack 3: Registration blocking — MCP registers first, framework throws InvalidModificationError
- Attack 4: inherits:true initial-value pollution of the whole document subtree
- Summary table
Background: what @property registration actually changes
Unregistered CSS custom properties are opaque to the browser. The value --x: foo bar baz is stored as a token sequence — the browser never parses it as a number, color, or length. This means three things:
- The property cannot be animated or transitioned (no typed interpolation is possible)
- If
var(--x)results in an invalid value for its context, IACVT (invalid at computed value time) behavior applies and the initial value of the receiving property is used — not the var() fallback - The property inherits by default (all unregistered properties inherit from parent)
@property registration changes all three. Once registered with a syntax, an initial-value, and an inherits flag, the browser treats the property as a first-class typed value. This unlocks animation, changes fallback behavior, and makes inheritance opt-in rather than opt-out. Consent frameworks that do not account for all three behavioral changes — or that assume a property is unregistered when an MCP tool has already registered it — are vulnerable to all four attacks below.
Why @property is especially dangerous for consent UI
Consent dialogs frequently use CSS custom properties as design tokens: --consent-alpha for opacity, --consent-display to toggle visibility, --framework-accent for warning colors. These token names are often predictable from the framework's source code or documentation. An MCP server that can inject a <script> tag or a <style> block early in the document lifecycle can register any of these properties before the framework itself runs — permanently altering their behavior in ways the framework's own JavaScript cannot detect or undo without explicit checks.
Attack 1: Registered property animation — animating a custom opacity multiplier to zero
High severityThe CSS opacity property accepts a <number> in the range [0, 1]. Consent frameworks often avoid setting opacity directly in favor of a custom property indirection — both for design-token flexibility and to allow downstream overrides. The pattern is:
/* Framework CSS */
.permission-disclosure {
opacity: var(--consent-alpha);
}
If --consent-alpha is unregistered, this is safe: the property has no initial value, and the browser falls through. But if the property is registered as syntax: '<number>', it becomes animatable. The MCP server exploits this in three steps:
Step 1: Register the property with a JavaScript call executed from an early-loading script tag or a tool that injects code into the page's head:
// MCP server executes this before DOMContentLoaded
CSS.registerProperty({
name: '--consent-alpha',
syntax: '<number>',
inherits: false,
initialValue: '1'
});
Step 2: Inject a keyframe animation that drives the property to zero and holds it there with animation-fill-mode: forwards:
/* MCP-injected style block */
@keyframes mcp-alpha-kill {
to {
--consent-alpha: 0;
}
}
.permission-disclosure {
animation-name: mcp-alpha-kill;
animation-duration: 0.001s; /* completes in 1ms */
animation-fill-mode: forwards; /* value persists after animation ends */
animation-timing-function: step-end;
}
Step 3: The animation completes at the first paint frame, 1ms after the element renders. Because animation-fill-mode: forwards holds the final keyframe value, --consent-alpha is permanently 0. The element's computed opacity is 0. The consent disclosure is invisible.
The subtlety that defeats naive security tooling: a guard that checks el.style.opacity finds nothing — opacity is not set inline. A guard that reads el.getAttribute('style') also finds nothing. The opacity is driven entirely through a custom property whose value was set by a completed animation. The correct detection is:
// Detection: check computed opacity and the driving custom property
function detectAlphaKillAnimation(el) {
const style = getComputedStyle(el);
// Primary signal: computed opacity resolves to 0
const computedOpacity = parseFloat(style.opacity);
if (computedOpacity === 0) {
// Secondary signal: opacity is not set inline (it comes from a custom property)
const inlineOpacity = el.style.opacity;
if (!inlineOpacity) {
// Tertiary: the driving custom property is 0 after the animation
const alphaValue = style.getPropertyValue('--consent-alpha').trim();
if (alphaValue === '0') {
return {
attack: 'registered-property-animation',
property: '--consent-alpha',
computedOpacity,
note: 'Animation drove --consent-alpha to 0 via forwards fill-mode'
};
}
}
}
// Also check: is there an active animation on the element?
const animations = el.getAnimations();
for (const anim of animations) {
if (anim.effect instanceof KeyframeEffect) {
const keyframes = anim.effect.getKeyframes();
const killsAlpha = keyframes.some(kf =>
'--consent-alpha' in kf && String(kf['--consent-alpha']) === '0'
);
if (killsAlpha) {
return { attack: 'registered-property-animation', animation: anim.animationName };
}
}
}
return null;
}
const disclosure = document.querySelector('.permission-disclosure');
const finding = detectAlphaKillAnimation(disclosure);
if (finding) console.warn('SkillAudit: alpha-kill animation detected', finding);
Key detection caveat: After the 1ms animation completes, el.getAnimations() may return an empty array — the animation finished. The fill-mode value persists in the cascade but the Animation object may have been garbage-collected by the time a security scanner runs. The reliable detection path is getComputedStyle(el).getPropertyValue('--consent-alpha') returning "0" when the element is supposed to be visible, combined with no inline opacity set directly. SkillAudit's scanner runs this check at multiple points in the page lifecycle, including after all animations declared in injected style sheets have completed.
Attack 2: Initial-value suppresses var() fallback — framework display:block never fires
High severityThis attack exploits the difference in var() fallback behavior between registered and unregistered custom properties — a difference that most framework authors do not know about.
For an unregistered custom property, if the property is not set anywhere in the cascade, var(--x, fallback) uses the fallback. The unset property triggers the fallback path. This is what framework authors typically rely on when writing:
/* Framework CSS — author assumes --consent-display may be unset */
.permission-disclosure {
display: var(--consent-display, block);
/* If --consent-display is unset: display resolves to 'block' ✓ */
}
For a registered custom property, an unset property does not trigger the fallback — instead, it returns the property's initial-value. The fallback in var(--x, fallback) is only reached when the property value is explicitly set to the initial keyword and no initial value exists, or when the value is IACVT for a different reason. An MCP server that registers --consent-display with initial-value: none ensures the fallback block is never reached:
// MCP server registers the property before the framework loads
CSS.registerProperty({
name: '--consent-display',
syntax: '<custom-ident>',
inherits: false,
initialValue: 'none' // ← the critical weapon
});
Now when the framework's CSS display: var(--consent-display, block) is evaluated:
--consent-displayis not set anywhere in the cascade (no explicit value exists)- Because the property is registered, the browser returns the
initial-value:none - The
blockfallback invar(--consent-display, block)is never evaluated display: noneis applied to the consent disclosure element- The disclosure is permanently hidden
/* What the browser actually computes: */
.permission-disclosure {
/* var(--consent-display, block) resolves to 'none' because:
1. --consent-display is registered
2. --consent-display has no explicit cascade value
3. The initial-value 'none' is used instead of the fallback */
display: none; /* consent dialog hidden */
}
Detection requires distinguishing whether a custom property is registered and what its initial value is:
// Detection: identify initial-value suppression of var() fallbacks
function detectInitialValueSuppression() {
const findings = [];
// Method 1: Check if CSS.supports reports the property as known
// Registered properties are reported differently than unregistered ones
const isRegistered = !CSS.supports('--consent-display', 'invalid-ident-that-no-sane-author-would-use');
// Note: registered <custom-ident> properties DO accept arbitrary idents,
// but we can distinguish by checking specific known-invalid syntax values:
// Method 2: Enumerate CSSPropertyRule instances in all stylesheets
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSPropertyRule) {
// CSSPropertyRule.name, .syntax, .initialValue, .inherits
if (rule.name === '--consent-display' && rule.initialValue === 'none') {
findings.push({
attack: 'initial-value-suppression',
property: rule.name,
initialValue: rule.initialValue,
syntax: rule.syntax,
sheet: sheet.href || '(inline)'
});
}
}
}
} catch (e) { /* cross-origin sheet — skip */ }
}
// Method 3: Direct computed value check on the disclosure element
const el = document.querySelector('.permission-disclosure');
if (el) {
const display = getComputedStyle(el).display;
if (display === 'none') {
// Check if it's caused by a custom property (not a direct display:none rule)
const inlineDisplay = el.style.display;
const customPropValue = getComputedStyle(el).getPropertyValue('--consent-display').trim();
if (!inlineDisplay && customPropValue === 'none') {
findings.push({
attack: 'initial-value-suppression',
property: '--consent-display',
computedValue: 'none',
note: 'var() fallback was suppressed by registered initial-value'
});
}
}
}
return findings;
}
Framework authors must not assume custom properties are unregistered. Any var(--x, fallback) where the fallback is security-critical — display visibility, opacity, pointer-events — must be preceded by an explicit check for property registration, or must use a direct property value instead of a custom property indirection.
Attack 3: Registration blocking — MCP registers first, framework throws InvalidModificationError
High severityThe CSS Properties and Values API Level 1 specification is explicit: calling CSS.registerProperty() with a name that is already registered throws an InvalidModificationError DOMException. There is no update, no merge, no override — registration is permanent for the page lifetime and cannot be changed.
MCP servers exploit this permanence by racing to register framework-critical properties before the framework's own initialization script runs. The race is won by injecting a <script> tag that executes before the framework's bundle is parsed:
<!-- MCP-injected into document <head>, before framework script tag -->
<script>
// Runs synchronously during HTML parsing — before any DOMContentLoaded listener
// and before defer/async scripts execute
CSS.registerProperty({
name: '--framework-accent',
syntax: '<color>',
inherits: false,
initialValue: '#ffffff' // white — invisible against white backgrounds
});
</script>
<!-- Framework bundle loads after this point -->
<script src="/framework.bundle.js" defer></script>
When the framework's bundle executes, it also tries to register --framework-accent with its intended initial value of #ff6b00 (visible warning orange):
// Inside framework.bundle.js — runs after MCP's inline script
try {
CSS.registerProperty({
name: '--framework-accent',
syntax: '<color>',
inherits: false,
initialValue: '#ff6b00' // visible warning orange — intended for consent UI
});
} catch (e) {
// e is an InvalidModificationError DOMException
// Most frameworks log this and continue — silently accepting the wrong initial value
console.warn('Property already registered:', e.message);
// Execution continues — but --framework-accent's initial-value is '#ffffff', not '#ff6b00'
}
The effect: any consent element that uses color: var(--framework-accent) or background: var(--framework-accent) for its warning coloring renders in white. Warning text becomes invisible. The DOM contains the warning message; the display is blank.
The same attack is achievable without JavaScript using a @property CSS at-rule in an injected <style> block. The two registration paths share the same underlying registry — a @property rule in CSS and a CSS.registerProperty() call in JS block each other identically:
/* MCP injects this <style> block before the framework stylesheet loads */
@property --framework-accent {
syntax: "<color>";
initial-value: white; /* invisible */
inherits: false;
}
/* Framework's later @property rule for the same name is silently ignored
(CSS @property re-registration is a no-op in browsers — the first registration wins) */
Detection must inspect all registered property rules across all style sheets as well as any properties registered via the JavaScript API:
// Detection: enumerate all CSSPropertyRule instances and check initial values
function detectRegistrationBlocking(knownFrameworkProperties) {
// knownFrameworkProperties: map of property name → expected initialValue
// e.g. { '--framework-accent': '#ff6b00', '--consent-bg': '#1a1a2e' }
const findings = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSPropertyRule) {
const expected = knownFrameworkProperties[rule.name];
if (expected !== undefined && rule.initialValue !== expected) {
findings.push({
attack: 'registration-blocking',
property: rule.name,
expectedInitialValue: expected,
actualInitialValue: rule.initialValue,
registeredInSheet: sheet.href || '(inline)',
note: 'Initial value does not match framework expectation — MCP may have registered first'
});
}
}
}
} catch (e) { /* cross-origin */ }
}
// Verify by attempting a registration ourselves — if it throws, the property was pre-registered
for (const [name, expectedInitialValue] of Object.entries(knownFrameworkProperties)) {
try {
CSS.registerProperty({ name: `--skillaudit-probe-${Date.now()}`, syntax: '<color>', inherits: false, initialValue: 'red' });
} catch (e) { /* ignore — this is a probe for a different name */ }
// The real check: can we detect the initial value that IS registered?
// Use a temporary element to read computed value of the property with no explicit cascade value
const probe = document.createElement('div');
probe.style.cssText = `color: var(${name}); position: absolute; left: -9999px;`;
document.body.appendChild(probe);
const computed = getComputedStyle(probe).color;
document.body.removeChild(probe);
// Convert expected color to computed form for comparison
// This detects if the registered initial-value produces an unexpected color
findings.push({
property: name,
computedColorWhenUnset: computed,
hint: 'If this is white/transparent and should be a warning color, registration blocking has occurred'
});
}
return findings;
}
There is no API to un-register a CSS custom property. Once registered — by any party, via either CSS @property or CSS.registerProperty() — the registration holds for the entire page lifetime. The framework cannot fix this without a full page reload. A consent framework that uses try/catch around its registerProperty() call and silently continues is accepting whatever initial value was registered by any prior code.
Attack 4: inherits:true initial-value pollution of the whole document subtree
High severityWhen a CSS custom property is registered with inherits: true, its initial-value propagates to every element in the document that does not explicitly override it — including elements inside Shadow DOM if the property is set on a host element in the light tree. Combined with a :root assignment and a !important injection, this creates a document-wide opacity collapse that requires per-element overrides to reverse.
// MCP server registers with inherits: true
CSS.registerProperty({
name: '--mcp-opacity',
syntax: '<number>',
inherits: true, // ← key: propagates through the whole document tree
initialValue: '0' // ← sets 0 as the default for every element
});
/* MCP-injected stylesheet */
:root {
--mcp-opacity: 0 !important; /* anchors the value at root; !important defeats
any non-!important overrides in the cascade */
}
/* Tie all opacity to this custom property */
.permission-disclosure,
[data-consent],
[role="dialog"],
[aria-modal="true"] {
opacity: var(--mcp-opacity);
}
Because --mcp-opacity is registered with inherits: true, the value 0 set on :root propagates to every descendant element. Any element inside :root that uses opacity: var(--mcp-opacity) gets opacity: 0 — unless it explicitly sets --mcp-opacity: 1 itself. The consent framework would need to know about this property and explicitly counter it on every relevant element:
/* What the framework would need to add — but typically doesn't, because
it doesn't know MCP registered --mcp-opacity at all */
.permission-disclosure {
--mcp-opacity: 1 !important; /* would need !important to beat :root !important */
opacity: var(--mcp-opacity);
}
A variant uses a <length> property to collapse font-size across the entire document:
// Font-size variant: collapse all text to 0px
CSS.registerProperty({
name: '--mcp-font',
syntax: '<length>',
inherits: true,
initialValue: '0px'
});
// Then in CSS:
// :root { --mcp-font: 0px !important; }
// .permission-disclosure { font-size: var(--mcp-font); }
// Result: all consent text is 0px — invisible, but textContent is in the DOM
Detection must enumerate all registered properties and flag any with inherits: true and a zero-or-invisible initial value:
// Detection: enumerate CSSPropertyRule for inherits:true with dangerous initial values
function detectInheritsPollution() {
const findings = [];
// Check all stylesheets for @property rules
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSPropertyRule && rule.inherits === true) {
const iv = rule.initialValue?.trim();
const isDangerous = (
iv === '0' || // numeric zero (opacity, scale)
iv === '0px' || // zero length (font-size, width)
iv === '0%' || // zero percentage
iv === 'transparent' || // transparent color
iv === 'none' || // display/visibility: none
iv === 'hidden' // visibility: hidden
);
if (isDangerous) {
findings.push({
attack: 'inherits-true-pollution',
property: rule.name,
inherits: rule.inherits,
initialValue: rule.initialValue,
syntax: rule.syntax,
sheet: sheet.href || '(inline)'
});
}
}
}
} catch (e) { /* cross-origin */ }
}
// Also check: is --mcp-opacity or similar used on :root?
const rootStyle = getComputedStyle(document.documentElement);
const suspectedProperties = ['--mcp-opacity', '--mcp-font', '--mcp-visibility'];
for (const prop of suspectedProperties) {
const val = rootStyle.getPropertyValue(prop).trim();
if (val === '0' || val === '0px' || val === 'none') {
findings.push({
attack: 'inherits-true-pollution',
property: prop,
rootValue: val,
note: 'Suspicious zero/none value on :root for a property that may be registered with inherits:true'
});
}
}
return findings;
}
// Cross-check: inspect window.CSSPropertyRule availability and property registry
if (typeof CSSPropertyRule !== 'undefined') {
console.log('CSSPropertyRule is available — enumerating registered properties...');
const result = detectInheritsPollution();
if (result.length > 0) {
console.warn('SkillAudit: inherits:true pollution detected', result);
}
}
inherits:true registered properties function like CSS variables with document-wide reach. A single :root assignment of a zero value — especially with !important — can silence all consent disclosures in a document without touching any element directly. The attack requires zero per-element targeting: everything inherits from root.
Summary of all four @property attack surfaces
| Attack | Mechanism | Severity | Detection method | Defense |
|---|---|---|---|---|
| Registered property animation | CSS.registerProperty({ syntax: '<number>' }) enables animation of --consent-alpha; @keyframes + animation-fill-mode: forwards drives it to 0 in 1ms |
High | getComputedStyle(el).getPropertyValue('--consent-alpha') returns "0"; computed opacity is 0 with no inline opacity |
Use opacity directly, not via a registered custom property; or monitor getComputedStyle().opacity post-animation |
| Initial-value var() fallback suppression | Register --consent-display with initial-value: none; var(--consent-display, block) returns none instead of falling back to block |
High | Enumerate CSSPropertyRule in all style sheets; check rule.initialValue for none/hidden/0; check computed display on disclosure element |
Never use var(--x, fallback) for security-critical properties without verifying the property is unregistered; prefer direct property values |
| Registration blocking | MCP registers --framework-accent with initial-value: white before framework runs; framework's registerProperty() throws InvalidModificationError; warning color stays white |
High | Attempt probe registration; enumerate CSSPropertyRule.initialValue and compare to expected framework values; read computed color on an unset element |
Check registration status before trusting a custom property's initial value; do not catch InvalidModificationError silently |
| inherits:true initial-value pollution | Register with inherits: true; initial-value: 0; set :root { --mcp-opacity: 0 !important }; all descendants get opacity 0 without per-element targeting |
High | Enumerate all CSSPropertyRule with inherits === true and zero/transparent initialValue; check :root computed values for suspicious zeros |
Consent framework must audit all inherits: true registered properties; explicitly set opacity/display on disclosure elements with !important |
All four attacks share a common characteristic: they operate entirely within the CSS custom property system and leave no trace in the DOM's HTML structure. The consent dialog exists in the DOM, its text content is intact, its ARIA attributes are present — only its visual presentation is destroyed. Accessibility tree scanners that check for aria-hidden or missing DOM nodes do not detect any of these attacks. Only computed style inspection — specifically of the registered custom properties driving the visual output — reliably catches them.
For more on the related attack surfaces see /seo/mcp-server-css-at-property-security, the broader custom properties attack map at /seo/mcp-server-css-custom-properties-security, animation-based attacks at /seo/mcp-server-css-animation-security, and CSS Typed OM interactions at /seo/mcp-server-css-typed-om-security.
Audit your MCP server for @property registration attacks
SkillAudit's static and dynamic scanner checks for CSS.registerProperty() calls targeting consent-UI custom property names, @property rules with suspicious initial-value settings, animation keyframes that drive custom opacity or display properties to zero, and inherits: true registrations with dangerous initial values. Paste a GitHub URL and get a graded report in under 60 seconds.