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:
syntax: the CSS type the property accepts, such as'<color>','<number>','<length>', or'*'(any).initial-value: the value the property resolves to when no value has been set anywhere in the inheritance chain. This is a guaranteed fallback — not the CSS-wide initial value, but one the author controls.inherits: whether the property inherits its value from the nearest ancestor that has set it (true) or always resets to the declaredinitial-valueon each element (false).
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:
- Controlled initial values. The attacker can register any custom property with an
initial-valueoftransparent,0, or another value that renders content invisible. If the disclosure element relies on that property viavar()and no ancestor sets the property, the initial value takes effect automatically. - Typed inheritance as a poisoning vector. With
inherits:true, a value set on an ancestor container propagates to every descendant that uses the property viavar(). 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. - 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@keyframesrule that transitionsopacity(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:
- Enumerate all
@propertydeclarations in the document's stylesheets. Walk everyCSSStyleSheetindocument.styleSheetsand collect allCSSPropertyRuleinstances (rule typeCSS.CSSRule.KEYFRAME_RULEequivalent for@property). For each registration, record the property name, its declaredsyntax,initial-value, andinheritsvalue. - Check initial-value for transparency and zero-opacity values. For any
@propertyregistration withsyntax:'<color>', parse theinitial-valueand check whether its alpha channel is zero (e.g.transparent,rgba(0,0,0,0)). Forsyntax:'<number>', check whetherinitial-valueis0. Flag any registration whose initial value would produce an invisible rendering result if used as a text color or opacity. - Cross-reference registered property names against
var()uses on disclosure elements. For each consent disclosure element, inspect the computed style and the matched CSS rules to identifyvar(--name)references incolor,background,background-color,opacity, andvisibility. If the referenced property name appears in the registered@propertylist with a hiding initial value, evaluate whether any ancestor between the element and the document root sets the property to a visible value. If not, flag as a high-severity finding. - Detect registered custom properties used as
@keyframesanimation targets. Scan all@keyframesdeclarations for keyframe properties that match registered@propertynames. For each match, check whether the animation's final computed value (accounting forfill-mode:forwards) produces a zero or low-contrast result for any disclosure element to which the animation is applied. Checkanimation-delayto identify time-delayed hiding attacks. - Run a contrast ratio check on all disclosure elements. Compute
getComputedStyle().colorandgetComputedStyle().backgroundColorfor each disclosure element and calculate the WCAG relative luminance contrast ratio. Flag any element whose contrast ratio is below 4.5:1 (WCAG AA). When a low-contrast finding is associated with avar()reference to a registered@propertywithinherits:false, report the specific@propertyregistration as the root cause.
SkillAudit findings
@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).
@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.
@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.
@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).