Security Guide

MCP server @property security — custom property type coercion, inherits poisoning, and animation attacks on consent disclosures

The CSS @property rule (part of the Houdini Properties and Values API) lets authors register custom CSS properties with a declared syntax type, an explicit initial value, and a controlled inheritance behaviour. This is a meaningful capability upgrade over untyped custom properties: the browser enforces the declared type at parse time, allows registered properties to participate in CSS animations and transitions, and propagates or blocks inheritance based on the inherits descriptor. For MCP servers operating in environments where CSS injection is possible, each of these capabilities introduces a distinct attack surface for hiding the consent and permission disclosures that users depend on to make informed decisions about what a server can access.

How @property works — and why it is an attack surface

Without @property, CSS custom properties are untyped strings. The browser stores whatever value is assigned, substitutes it at use sites via var(), and performs type resolution only when the substituted value is used in a typed context. If the resolved value is invalid for the property being set, the browser falls back to the initial or inherited value — not to the custom property's own initial value, because untyped properties have none.

@property changes this by registering a custom property with three descriptors:

A minimal example:

@property --disclosure-color {
  syntax: '<color>';
  initial-value: #000000;
  inherits: true;
}

This registers --disclosure-color as a typed color property that defaults to black and is inherited. Any element that sets color: var(--disclosure-color) will render black text unless some ancestor or the element itself sets --disclosure-color to a different value.

Three capabilities introduced by @property are directly exploitable for hiding consent disclosures:

  1. Controlled initial values. The attacker can register any custom property with an initial-value of transparent, 0, or another value that renders content invisible. If the disclosure element relies on that property via var() and no ancestor sets the property, the initial value takes effect automatically.
  2. Typed inheritance as a poisoning vector. With inherits:true, a value set on an ancestor container propagates to every descendant that uses the property via var(). Because the property is typed (e.g. <number>), calc() expressions that reference it produce valid, computed numeric values rather than the "invalid at computed-value time" results that untyped properties produce in the same calc context.
  3. Keyframe animation of registered properties. Untyped custom properties cannot be interpolated between keyframe values: the browser does not know the type, so it cannot compute intermediate values. A registered <number> or <color> property can be animated. An attacker can define a @keyframes rule that transitions opacity (or a custom property used for opacity) from 1 to 0 with a delay, creating a time window where the disclosure appears visible but later becomes invisible.

Attack 1 — @property initial-value:transparent overriding disclosure text color

The most direct attack: register a custom color property with an initial value of transparent and use it as the text color on the consent disclosure element. Because the disclosure element's ancestor chain does not set the custom property, the registered initial value — transparent — is used everywhere the property appears via var().

@property --text-color {
  syntax: '<color>';
  initial-value: transparent;  /* attacker sets initial value to transparent */
  inherits: true;
}

.mcp-disclosure {
  color: var(--text-color);
  /* resolves to transparent: no ancestor sets --text-color,
     so the @property initial-value (transparent) is used */
}

/* What basic audits see — all pass: */
/* display:      block        ← not "none"             */
/* visibility:   visible      ← not "hidden"           */
/* opacity:      1            ← not zero               */
/* offsetHeight: > 0         ← has layout dimensions  */
/* textContent:  [full text]  ← content is in DOM      */
/* aria-hidden:  absent       ← accessible tree intact */

/* What getComputedStyle() reveals — catches the attack: */
/* color: rgba(0, 0, 0, 0)   ← fully transparent       */

Detection gap: The disclosure text is present in the DOM, present in the accessibility tree, and returned correctly by textContent. Screen readers may announce the element depending on their implementation. Only reading getComputedStyle(element).color and testing for a zero alpha channel reveals that the text renders invisibly to sighted users. An audit that checks display, visibility, and opacity passes all three checks and misses this attack entirely.

The attack is also detectable by inspecting the document's stylesheets for @property declarations whose initial-value resolves to a color with zero alpha, and then cross-referencing those property names against var() references on disclosure elements' color, background-color, or background properties. If a match is found and no ancestor between the disclosure and the document root sets the property to an opaque value, the transparent initial value is guaranteed to be the computed color.

Attack 2 — inherits:true poisoning an inherited custom property through the consent container

This attack uses the typed inheritance mechanism to push a hiding value from the consent container element down to the disclosure element. The attacker registers a custom numeric property with an initial value of zero and sets the property on the container. Because the property is typed as <number>, it can participate in calc() expressions and produce a valid computed opacity of zero — something that is not achievable with untyped custom properties.

@property --visibility-mode {
  syntax: '<number>';
  initial-value: 0;   /* 0 = hidden in the attacker's scheme */
  inherits: true;
}

.mcp-consent-container {
  --visibility-mode: 0;   /* set on the CONTAINER, not on the disclosure */
}

.mcp-disclosure {
  opacity: calc(var(--visibility-mode) * 1);
  /* --visibility-mode inherits as 0 from the container
     calc(0 * 1) = 0 → opacity: 0 → element is invisible */
}

/* Without @property registration, this attack fails:           */
/* calc(var(--visibility-mode) * 1) with an UNTYPED property   */
/* resolves to "invalid at computed-value time" — NOT to 0.    */
/* The @property registration is a prerequisite for the attack. */

Why @property is required for this attack: Without the @property registration, --visibility-mode is an untyped string. When the browser evaluates calc(var(--visibility-mode) * 1), it substitutes the string value 0 and attempts to parse it as a number inside calc(). Because untyped custom property values are strings, the substitution produces calc("0" * 1), which is invalid at computed-value time. The opacity property falls back to its CSS-wide initial value of 1 — the element remains visible and the attack fails. The @property registration with syntax:'<number>' is what makes the calc() expression produce a valid computed number, enabling the attack.

The root cause of this attack is set on the container, not the disclosure. An audit that inspects only the disclosure element's inline styles or directly assigned classes may not identify that opacity:0 originates from an inherited custom property set several DOM levels up. Detection requires tracing each computed property that contributes to visibility — including custom properties referenced in calc() expressions for opacity — through the cascade and inheritance chain to identify where the value was set and whether a @property registration with a hiding initial value is in scope.

Attack 3 — @keyframes animation of a registered custom property for time-delayed opacity collapse

This attack exploits the typed animation capability that @property adds to custom properties. Because --reveal-state is registered as a <number>, the browser can interpolate it between keyframe values. The attacker uses this to animate the disclosure's opacity from 1 to 0 after a 2-second delay — the element is visible during initial load, passes any audit run at page-load time, and then fades permanently to invisible once the animation completes.

@property --reveal-state {
  syntax: '<number>';
  initial-value: 0;
  inherits: false;
}

@keyframes hide-disclosure {
  from { --reveal-state: 1; }
  to   { --reveal-state: 0; }
}

.mcp-disclosure {
  opacity: var(--reveal-state);
  animation: hide-disclosure 0.5s ease-out 2s forwards;
  /*                                        ^-- 2s delay
     - t=0 to t=2s:   opacity is 1 (initial-value overridden by keyframe from-value)
     - t=2s to t=2.5s: opacity transitions from 1 to 0
     - t>2.5s:          opacity remains at 0 (forwards fill mode)
     Without @property, animating --reveal-state between numeric keyframe values
     is not possible — the browser cannot interpolate an untyped string. */
}

/* What getComputedStyle() reports at t=0 (audit-time):         */
/* opacity: 1           ← passes audit                          */
/* animation: hide-disclosure 0.5s ease-out 2s forwards ← visible */

/* What getComputedStyle() reports at t=3s (user-time):         */
/* opacity: 0           ← disclosure is permanently invisible   */

Audit-time-of-check vs user-time-of-display: A scanner that runs at page-load time evaluates opacity during the 2-second pre-animation window and records it as 1. The user interacting with the consent dialog at 3 seconds sees an invisible disclosure. The animation fill mode forwards means the element never returns to opacity:1 after the animation completes — the hiding is permanent. This is a classic time-of-check/time-of-use vulnerability applied to CSS auditing.

Detecting this attack requires two complementary checks. First, enumerate all @property registrations in the document's stylesheets and identify which registered custom properties are referenced in opacity, visibility, or color values on disclosure elements. Second, check whether any of those registered properties are targeted as keyframe targets in @keyframes declarations, and whether the animation's end state produces a hiding computed value. An animation delay check — specifically whether the animation-delay is greater than zero for any disclosure-affecting animation — is a high-signal indicator of a time-delayed hiding attack.

Attack 4 — inherits:false with white-on-white contrast via a fixed initial-value

This attack uses the inherits:false descriptor to ensure that a custom background-color property always resolves to its declared initial value — white — on the disclosure element, regardless of what the parent sets. Combined with an explicit white text color on the disclosure, the result is white text on a white background: the disclosure is invisible to sighted users at contrast ratio 1.0:1.

@property --bg {
  syntax: '<color>';
  initial-value: #ffffff;   /* white */
  inherits: false;
}

.mcp-disclosure {
  color: #ffffff;           /* white text */
  background: var(--bg);   /* resolves to initial-value #ffffff — also white */
  /* white text on white background: rendered invisible */
}

/* Why inherits:false matters here:                                     */
/* If the parent sets --bg to a dark color, that value DOES NOT         */
/* propagate to .mcp-disclosure because inherits:false resets the       */
/* property to its initial-value on each element that does not          */
/* explicitly set --bg on itself.                                       */
/* The disclosure's --bg is ALWAYS #ffffff unless the disclosure itself */
/* sets the property — the parent's value is irrelevant.               */

/* What getComputedStyle() reports:                                     */
/* color:            rgb(255,255,255)  ← white                         */
/* background-color: rgb(255,255,255)  ← white                         */
/* Contrast ratio: 1.0:1 (no contrast whatsoever)                      */
/* textContent: [correct permission text] ← passes content checks      */

Why inherits:false strengthens this attack: Without the @property registration, if the parent element sets --bg to a dark color and the disclosure uses background: var(--bg), the dark value would inherit and the disclosure background would not be white — the attack would fail if any ancestor happened to set the property. The inherits:false registration guarantees that the disclosure's background is always the declared initial value regardless of what ancestors set, making the white-on-white result deterministic and not dependent on the surrounding DOM context.

Detection relies on computing the contrast ratio between the disclosure element's getComputedStyle().color and getComputedStyle().backgroundColor. A contrast ratio below the WCAG AA minimum of 4.5:1 is a reliable signal of a rendering-based hiding attack. For this specific variant, additionally flagging any @property declaration with inherits:false and an initial-value that would produce low contrast when used as a background against the element's explicit text color provides earlier, rule-based detection before runtime contrast computation is required.

Summary table

Attack Mechanism Severity What detects it
initial-value:transparent on color property @property initial-value:transparent used as disclosure text color via var(); no ancestor sets the property High getComputedStyle().color alpha channel === 0
inherits:true poisoning via ancestor container Custom <number> property set to 0 on container; flows to disclosure as opacity:0 via typed calc() High Tracing inherited custom property through cascade; checking @property registration enables typed calc()
@keyframes animation of registered property @property enables keyframe interpolation of <number> property; 2s delay hides from load-time audits High Checking @property-animated opacity on disclosure; detecting animation-delay > 0 with forwards fill
inherits:false white-on-white via fixed initial-value @property initial-value:#ffffff; inherits:false guarantees white background regardless of ancestors Medium Contrast ratio check (color vs background-color); flag @property inherits:false causing low-contrast initial-value

Detection checklist

Detecting all four @property attack variants requires inspecting both the registered custom properties and their downstream effects on computed visual properties. SkillAudit's methodology covers the following controls:

SkillAudit findings

High @property --text-color { syntax:'<color>'; initial-value:transparent; inherits:true } found. Consent disclosure element uses color:var(--text-color). No ancestor sets --text-color; resolved value is transparent. DOM content and textContent return correct permission text but element is invisible to sighted users. getComputedStyle().color returns rgba(0,0,0,0).
High @property --vis { syntax:'<number>'; initial-value:0; inherits:true } registered. Consent container sets --vis:0. Disclosure element computes opacity:calc(var(--vis)*1) = opacity:0. Removal of @property declaration causes type mismatch — untyped property calc() returns invalid-at-computed-time, not 0. Registration is required for the attack to work.
High @keyframes hide { from{--reveal:1} to{--reveal:0} } targeting @property --reveal { syntax:'<number>'; initial-value:1 }. Animation applied to disclosure: animation:hide 0.5s ease-out 2s forwards. At t=0 through t=2s, disclosure opacity is 1 and audit passes. At t=2.5s, opacity is 0. Audit-time-of-check does not equal user-time-of-display.
Medium @property --bg { syntax:'<color>'; initial-value:#ffffff; inherits:false } and disclosure has color:#ffffff; background:var(--bg). Computed contrast ratio: 1.0:1 (white-on-white). textContent returns correct permission text; visual output is invisible to sighted users. inherits:false guarantees that no ancestor can override the white background on this element.

Related security guides: @property attacks are one dimension of the CSS Houdini attack surface. See also untyped CSS custom property attacks (hiding via unregistered custom properties), CSS animation attacks on consent disclosures (keyframe and transition-based hiding without Houdini), and Houdini API attack surface overview (Paint API, Layout API, and Typed OM attack surfaces).