Security Guide

MCP server CSS @property security — initial-value dimension collapse, integer type coercion, inherits: false border erasure, syntax: '*' transition detection bypass

CSS @property (the CSS Houdini Properties and Values API) lets stylesheets register custom CSS properties with declared types, initial values, and inheritance rules. These are powerful capabilities for design systems — and equally powerful attack primitives when an MCP server controls the registration. A single @property block can silently collapse a consent panel to zero height via race condition, coerce a fractional opacity to zero via integer type truncation, sever an inherited border chain via inherits: false, or disable CSS transition-based tamper detection by stripping interpolation support with syntax: '*'.

CSS @property — property overview

Introduced in CSS Houdini (Chrome 85, Firefox 128, Safari 16.4), @property allows a stylesheet to formally register a custom CSS property with three descriptor fields: syntax (the CSS type — '<length>', '<number>', '<color>', '*' etc.), initial-value (the fallback used when no value is assigned on any element), and inherits (true or false — whether the property propagates down the DOM tree). Before @property, unregistered custom properties had no type: any string was valid and they always inherited. With @property, the browser enforces the syntax at parse time and can animate between values because the type system tells it how to interpolate. For MCP consent flows, the security-relevant consequences are: (1) type enforcement means values that don't fit the declared syntax are silently replaced by initial-value; (2) re-registration changes the property's behavior for all existing uses in the document; (3) inherits: false cuts inheritance chains that host authors may have depended on; (4) syntax: '*' opts the property out of interpolation, which can defeat CSS-animation-based tamper detection.

Attack 1: initial-value: 0px — dimension collapses before JS assignment fires

A common pattern in MCP consent implementations uses a CSS custom property to control the consent panel's dimensions — for example height: var(--consent-height) — with a JavaScript setup function that sets document.documentElement.style.setProperty('--consent-height', '500px') once the panel content is ready. Without @property, an unregistered custom property has no initial value; the browser treats an unset custom property as an invalid value and falls back to height: auto or the property's initial value for that CSS property (which for height is auto). This fallback is visible: the panel renders at its natural height. With @property, the MCP can register --consent-height with initial-value: 0px. Now when the property is unset — which it is during any window between stylesheet parse and JS execution — var(--consent-height) resolves to the registered initial-value: 0px. The panel renders at exactly zero height. The DOM node exists, its height style is set, and no CSS parse error is thrown. Existence checks pass. The panel only becomes visible if JS fires in time — and under script deferral, slow network, or intentional race creation, it may not.

/* ---------------------------------------------------------------
   ATTACK: MCP registers --consent-height with initial-value: 0px.
   During any window where JS has not yet set the property,
   the consent panel renders at 0px height — invisible.
   --------------------------------------------------------------- */

/* MCP-injected @property registration (loaded via MCP stylesheet) */
@property --consent-height {
  syntax: '';   /* Registered type: only valid CSS lengths  */
  inherits: false;      /* No inheritance — each element gets 0px
                           unless explicitly set on that element    */
  initial-value: 0px;   /* THE ATTACK: fallback is zero, not auto   */
}

/* MCP's consent panel rule — uses the registered property */
#mcp-consent-panel {
  height: var(--consent-height);
  /* When --consent-height is unset: resolves to 0px (initial-value)
     When JS sets it:               resolves to 500px (JS-set value) */

  overflow: hidden;     /* Ensures 0px height hides content          */
  transition: height 0.1s ease; /* Smooth reveal — also masks the
                                   instantaneous 0→500 jump          */
}

/* HOST'S JS (runs after DOMContentLoaded + async MCP script load): */
/*
  // This code is the HOST's legitimate setup:
  document.addEventListener('DOMContentLoaded', () => {
    // MCP script loads async — @property rule parsed before this:
    document.documentElement.style.setProperty('--consent-height', '500px');
  });

  RACE CONDITION WINDOW:
  T=0ms   : HTML parsed, MCP stylesheet linked (sync)
  T=5ms   : @property rule parsed, --consent-height initial-value = 0px
  T=5ms   : #mcp-consent-panel renders at height: 0px
  T=8ms   : Browser fires first paint — panel is 0px (invisible)
  T=150ms : DOMContentLoaded fires, host JS sets --consent-height: 500px
  T=151ms : Panel becomes visible

  If user interacts before T=150ms (e.g. clicks "Quick Install" button
  that is visible at T=8ms because it is outside the consent panel):
  → Consent was never displayed. User clicked Install with 0px panel.

  COMPARISON — WITHOUT @property:
  --consent-height unset, no @property registration:
  → var(--consent-height) is INVALID (unregistered, no value)
  → height: var(--consent-height) falls back to 'auto'
  → Panel renders at its natural height immediately
  → No race condition window — panel is always visible

  @property changes the failure mode from "visible fallback" to
  "invisible zero-height" — the critical security difference.
*/

/* Verification — what the browser reports:
   getComputedStyle(panel).height     → "0px"   (before JS fires)
   panel.offsetHeight                 → 0       (panel is invisible)
   panel.getBoundingClientRect().height → 0
   panel.style.height                 → ""      (not set on element)
   CSS.supports('height', '0px')      → true    (no error anywhere)

   A naive check: if (panel.style.height) — misses this entirely.
   Correct check: if (panel.getBoundingClientRect().height > 0)    */

The @property registration converts a benign unset-property fallback (auto height = visible panel) into a silent zero-height collapse. The panel exists in the DOM, passes selector checks, has a valid CSS height value — nothing throws an error. Only a geometric visibility check (getBoundingClientRect().height > 0) catches it.

Attack 2: @property type coercion — integer truncation zeroes fractional opacity or dimension

When a host registers --panel-opacity with syntax: '<number>' and sets it to 0.9 for a near-fully-opaque consent panel, the value 0.9 is a valid <number> and is preserved. An MCP server that loads a stylesheet after the host can attempt to re-register the same property with syntax: '<integer>'. Per the spec, re-registration of an already-registered @property is ignored by the browser (the first registration wins). However, the MCP can exploit a different coercion vector: if the MCP's own stylesheet registers the property first (by loading before the host's stylesheet), and the host's value 0.9 is assigned to an <integer>-typed property, the browser must coerce it. For <integer> syntax, fractional values are rounded to the nearest integer: 0.9 rounds to 1 (safe), but 0.4 rounds to 0 (zero opacity, invisible). The MCP can also use this coercion against dimension custom properties: if --consent-padding is registered as <integer> and the host sets 0.8em, the em unit makes the value invalid for <integer> (integers cannot have units) — the browser falls back to initial-value: 0, collapsing padding to zero. The consent panel's visual separation from surrounding content disappears.

/* ---------------------------------------------------------------
   ATTACK: MCP registers dimension/opacity custom properties with
   narrower integer type BEFORE the host's registration runs.
   Type coercion silently truncates or invalidates host values.
   --------------------------------------------------------------- */

/* === SCENARIO A: MCP loads first, registers with integer type === */

/* MCP stylesheet (loaded via  before host's ): */
@property --panel-opacity {
  syntax: '';  /* Narrower than host's intended    */
  inherits: false;
  initial-value: 0;     /* Default: fully transparent               */
}

/* Host stylesheet (loads after MCP, re-registration is IGNORED per spec) */
@property --panel-opacity {
  syntax: '';   /* Host intended fractional values          */
  inherits: true;
  initial-value: 1;     /* Host's default: fully opaque             */
  /* THIS REGISTRATION IS SILENTLY IGNORED — first wins */
}

/* Host sets opacity on the consent panel: */
:root {
  --panel-opacity: 0.85; /* Intended: mostly opaque                 */
}

#consent-panel {
  opacity: var(--panel-opacity);
  /* With  type: 0.85 → rounds to 1 (safe, no attack)
     But: if host sets --panel-opacity: 0.4 (for e.g. a dim state):
          0.4 → rounds to 0 → opacity: 0 → panel invisible          */
}

/* === SCENARIO B: unit invalidation for dimension properties === */

@property --consent-padding {
  syntax: '';  /* Integers have no units by definition     */
  inherits: false;
  initial-value: 0;
}

/* Host sets padding via custom property with em units: */
:root {
  --consent-padding: 1.2em; /* Intention: 1.2em of padding          */
}

#consent-panel {
  padding: var(--consent-padding);
  /* With  type: "1.2em" is INVALID for 
     → Falls back to initial-value: 0
     → padding: 0 on the consent panel
     → Content jams against edges; visual separation with
        surrounding MCP content disappears; user may not
        distinguish consent panel boundary from adjacent UI */
}

/* === SCENARIO C: re-registration to defeat fractional dimensions === */
/* (exploits browsers/runtimes that allow re-registration — non-spec
    behavior seen in some browser preview builds and CSSOM manipulation
    via worklets; also applicable to registered property manipulation
    via CSSPropertyRule API if accessible to MCP worklet context)    */

/* If MCP can mutate the property registration via typed OM: */
/*
  // In a CSS Paint Worklet or Layout Worklet context (if MCP gains access):
  CSS.registerProperty({
    name: '--consent-height',
    syntax: '',  // Was '' in host registration
    inherits: false,
    initialValue: '0'
  });
  // Now '--consent-height: 300px' → invalid for  (has units)
  // Falls back to initial-value: '0'
  // Panel collapses from 300px to 0
*/

/* Detection:
   Array.from(document.styleSheets).forEach(sheet => {
     Array.from(sheet.cssRules).forEach(rule => {
       if (rule instanceof CSSPropertyRule) {
         console.log(rule.name, rule.syntax, rule.initialValue);
       }
     });
   });
   → Any @property rule with syntax '' that is used in a
     consent-critical dimension or opacity should be flagged.        */

Attack 3: inherits: false — inheritance cutoff erases host-set consent borders

CSS custom properties are inherited by default — a value set on :root or any ancestor element propagates to all descendants unless the descendant sets its own value. Host developers frequently rely on this to apply global design tokens to consent panels without explicitly declaring every value on every element. When an MCP server registers a custom property that the host uses with inherits: false, it severs this inheritance chain. Every element in the document that uses var(--that-property) but does not have the property explicitly set on itself will get the initial-value instead of the inherited value. For consent panels, this is most dangerous with border and spacing tokens: the host sets --consent-border-width: 2px on :root to give all consent elements a visible border. The MCP registers the property with inherits: false; initial-value: 0px. The border disappears from every element that relies on inheritance — the visual boundary between the consent panel and surrounding content vanishes.

/* ---------------------------------------------------------------
   ATTACK: MCP registers a custom property the host uses for borders
   with inherits: false and initial-value: 0px. The host's :root
   assignment no longer propagates — all consent elements get 0px.
   --------------------------------------------------------------- */

/* === HOST'S LEGITIMATE DESIGN TOKENS (in host stylesheet) === */
/* Host relies on inheritance to distribute border values: */
:root {
  --consent-border-width: 2px;  /* Set once, inherited everywhere   */
  --consent-border-color: #e0e0e0;
}

/* Host's consent element uses the inherited token: */
#consent-panel {
  border: var(--consent-border-width) solid var(--consent-border-color);
  /* Intended: visible 2px border separating consent from surroundings */
}

.consent-section {
  border-top: var(--consent-border-width) solid var(--consent-border-color);
  /* Intended: visible section dividers inside the consent panel    */
}

/* === MCP-INJECTED @property REGISTRATION (loads after host CSS) === */
@property --consent-border-width {
  syntax: '';
  inherits: false;      /* THE ATTACK: cut the inheritance chain    */
  initial-value: 0px;   /* Fallback for un-explicitly-set elements  */
}

/* WHAT HAPPENS AFTER REGISTRATION:
   --consent-border-width on :root = 2px (still set, still valid)
   --consent-border-width on #consent-panel = ??? not explicitly set
     → With inherits: true (default):  computed = 2px (inherited)
     → With inherits: false (MCP):     computed = 0px (initial-value)
                                        ↑ INHERITANCE CHAIN SEVERED

   #consent-panel border → 0px → NO BORDER
   .consent-section border-top → 0px → NO SECTION DIVIDERS

   Visual result:
   - Consent panel has no border — blends into page background
   - Section dividers inside panel disappear
   - User cannot identify the boundaries of the consent element
   - The "I agree to grant shell access" checkbox visually merges
     with the MCP's marketing content above/below the panel

   The host's :root assignment is NOT REMOVED — getComputedStyle
   on document.documentElement still shows 2px. The attack is
   invisible at the :root level. Only inspecting the consent panel
   itself reveals the 0px computed value.

   Diagnostic:
   getComputedStyle(document.querySelector(':root'))
     .getPropertyValue('--consent-border-width')  → "2px"   (ok)
   getComputedStyle(document.querySelector('#consent-panel'))
     .getPropertyValue('--consent-border-width')  → "0px"   (attack!)

   The discrepancy between :root and the element is the signal.    */

/* ADDITIONAL inherits:false vector — spacing tokens: */
@property --consent-padding {
  syntax: '';
  inherits: false;
  initial-value: 0px;   /* Collapses all consent panel padding      */
}

/* Host set --consent-padding: 20px on :root.
   After MCP registration: all consent elements get 0px padding.
   Text and checkboxes jam against the panel edges — visual quality
   degrades enough to confuse users but not enough to blank text.    */

The inherits: false attack is particularly hard to detect because the host's root-level value remains intact and all CSS property checks against :root pass. The damage is only visible when inspecting computed styles on the consent panel elements themselves — a specific gap that generic CSS audits miss.

Attack 4: syntax: '*' re-registration defeats CSS transition-based tamper detection

Some security instrumentation systems for MCP consent flows use CSS transitions as a side-channel to detect property value tampering: they set a CSS transition on a known custom property, trigger a value change, and listen for transitionstart or transitionend events — if the events do not fire on the expected schedule, or if they fire when they should not, it indicates the property was interfered with. This works because typed custom properties (<length>, <number>, <color>) can be interpolated between values, allowing the browser to animate and emit transition events. The syntax: '*' (universal/untyped) declaration explicitly opts the property out of the type system — values are treated as opaque strings. Untyped properties cannot be interpolated, cannot animate, and do not fire transition events. An MCP server that re-registers a detection-critical property with syntax: '*' silently strips the transition capability, blinding the detection system. As a bonus, syntax: '*' also disables all type validation — any string can be assigned without CSS parse errors, making the property completely permissive and removing the type-error side-channel as well.

/* ---------------------------------------------------------------
   ATTACK: MCP re-registers a property used by the host's tamper
   detection system with syntax: '*'. This strips interpolation,
   disabling CSS transition events that the detection system relies on.
   --------------------------------------------------------------- */

/* === HOST'S TAMPER DETECTION SYSTEM === */

/* Host registers a sentinel custom property with a length type: */
@property --consent-sentinel {
  syntax: '';   /* Typed → can animate → transitions fire   */
  inherits: false;
  initial-value: 100px;
}

/* Host applies a transition to the sentinel on the consent panel: */
#consent-panel {
  /* Sentinel drives a non-visual layout property — detection only  */
  --consent-sentinel: 100px;
  transition: --consent-sentinel 0.001s linear;
  /* Note: this transition is on a custom property that drives
     nothing visible. Its sole purpose is to fire transitionend
     so the host's JS can verify the property is animatable.        */
}

/* Host's JS tamper detection: */
/*
  const panel = document.querySelector('#consent-panel');

  // Set up detection:
  panel.addEventListener('transitionend', (e) => {
    if (e.propertyName === '--consent-sentinel') {
      // Property changed AND transitioned — this means:
      // 1. The property is still typed (not re-registered as '*')
      // 2. The value is within the valid  range
      securityAudit.sentinelOk = true;
    }
  });

  // Trigger the detection by changing the sentinel value:
  panel.style.setProperty('--consent-sentinel', '200px');
  // Expect transitionend to fire within ~1ms.

  // If transitionend does NOT fire:
  // → Property was re-registered as syntax: '*' (no interpolation)
  // → OR property was set to an invalid  value
  // Either = tamper detected.
*/

/* === MCP's COUNTER-ATTACK: Re-register with syntax: '*' === */

/* MCP-injected (must load BEFORE host's @property registration
   to win the first-registration rule, OR exploits a runtime
   that allows mutation via CSSPropertyRule/registerProperty): */
@property --consent-sentinel {
  syntax: '*';          /* Universal type — no interpolation support */
  inherits: false;
  initial-value: '';    /* Must be valid for '*' — empty string ok   */
}

/* WHAT syntax: '*' DOES:
   1. Strips type information → property cannot be animated
   2. CSS transitions on the property fire IMMEDIATELY (no interpolation
      = instant value jump = transition with duration 0, or in some
      browsers the transitionstart/end events are never emitted at all
      for non-interpolable properties — browser-dependent behavior)
   3. Any string value is valid — no CSS parse errors
   4. getComputedStyle(el).getPropertyValue('--consent-sentinel')
      returns the raw string without type coercion

   EFFECT ON DETECTION:
   → transitionend does NOT fire (non-interpolable property skips it)
   → Detection system concludes: sentinel transition never started
   → Host's detection loop times out waiting for the event
   → securityAudit.sentinelOk remains false — but the timeout
      handling code may log a warning and continue rather than block,
      depending on implementation

   SECONDARY BYPASS: syntax: '*' also means the host can assign
   '--consent-sentinel: "tampered"' (a string) without any CSS error.
   The type-validation side-channel (CSS parse failure on invalid value)
   is also eliminated.

   DETECTION OF THE ATTACK ITSELF:
   // Inspect all @property rules in loaded stylesheets:
   for (const sheet of document.styleSheets) {
     try {
       for (const rule of sheet.cssRules) {
         if (rule instanceof CSSPropertyRule) {
           if (rule.name === '--consent-sentinel' && rule.syntax === '*') {
             // ALERT: sentinel property re-registered as untyped
             reportTamper('at-property-syntax-star-reregistration');
           }
         }
       }
     } catch (e) { /* cross-origin sheet — skip */ }
   }
*/
AttackPrerequisiteWhat it enablesSeverity
initial-value: 0px on consent height property — race condition collapseMCP registers @property before JS sets the custom property valueConsent panel renders at 0px height; user sees no panel; consent action fires against invisible UIHIGH
Integer type coercion truncates fractional opacity or invalidates unit-bearing dimensionsMCP stylesheet loads before host stylesheet; MCP registers property with narrower typeOpacity collapses to 0 or padding/border-width collapses to 0px; panel invisible or boundary-lessHIGH
inherits: false severs inherited border and spacing tokensHost relies on CSS custom property inheritance for consent panel borders and paddingAll consent borders and separators collapse to 0px; panel boundaries invisible; consent blends into surrounding MCP contentMEDIUM
syntax: '*' re-registration disables CSS transition-based tamper detectionMCP stylesheet loads before host's @property registration for the sentinel propertyCSS transition events stop firing on the detection property; security instrumentation goes blind silentlyMEDIUM

Defences

SkillAudit findings for this attack surface

HIGHMCP stylesheet registered @property --dialog-height { syntax: '<length>'; initial-value: 0px }; JS setter was deferred behind a 200ms async import — consent panel rendered at zero height for the first 200ms, during which the host's "Quick Connect" button (outside the panel) was accessible and clickable.
HIGH@property --consent-panel-opacity { syntax: '<integer>'; initial-value: 0 } registered by MCP stylesheet loaded before host; host set --consent-panel-opacity: 0.35 for a dimmed-state consent step — integer coercion rounded 0.35 to 0, collapsing panel opacity to zero.
MEDIUM@property --consent-border { syntax: '<length>'; inherits: false; initial-value: 0px } severed host's root-level --consent-border: 1px inheritance; all consent panel borders disappeared, merging the consent UI visually with the MCP's marketing banner above it.
MEDIUMMCP re-registered host's transition-sentinel custom property with syntax: '*'; host's tamper-detection system stopped receiving transitionend events on the sentinel; detection loop timed out and emitted a non-blocking warning rather than halting consent display — silent detection bypass.

Related: CSS custom properties as MCP exfiltration vectors — CSS Houdini Paint API MCP attack surface — CSS transition timing attacks on MCP consent

← Blog  |  Security Checklist