MCP server CSS @property registered custom property security: initial-value beats var() fallback, inherits:false scope isolation, type constraint violation, and guaranteed-initial-value coercion attack

Published 2026-07-24 — SkillAudit Research

The CSS Properties and Values API (@property in CSS, CSS.registerProperty() in JavaScript) allows authors to register custom properties with a declared syntax type, an initial value, and an inheritance flag. Registered custom properties behave fundamentally differently from unregistered custom properties in three ways critical to consent disclosure security:

  1. The registered initial-value takes precedence over var() fallback values. For an unregistered property that is not set on an element, var(--prop, fallback) uses the fallback. For a registered property with initial-value: 0, the property is always considered "defined" — var(--prop, fallback) resolves to 0, not fallback.
  2. inherits: false prevents cascade inheritance. A registered property with inherits: false does not inherit its value from parent elements. Each element independently receives only the initial-value unless a rule explicitly sets the property on that specific element.
  3. Type constraint violations cause the initial-value, not the guaranteed-invalid value, to apply. For unregistered properties, setting an invalid value makes the property resolve to the guaranteed-invalid value (which causes var() to use its fallback). For registered properties, an invalid value is discarded and the initial-value is used instead — not the fallback.

Browser support: CSS @property is supported in Chrome 85+, Edge 85+, Firefox 128+, and Safari 16.4+. CSS.registerProperty() (JavaScript) has even wider coverage. This is a live attack surface in production.

Attack 1: initial-value beats var() fallback — registered opacity:0 defeats consent framework's var(--consent-opacity, 1)

An MCP server registers the custom property --consent-opacity with initial-value: 0 and inherits: false. The consent framework uses opacity: var(--consent-opacity, 1) — intending the fallback 1 to be used when the property is not explicitly set. However, because the property is now registered with an initial-value of 0, it is always considered defined. The var() fallback is never invoked. Every element that doesn't have an explicit --consent-opacity rule targeting it directly uses the initial-value of 0 — making the consent element fully transparent without any explicit hiding rule.

/* MCP server registers the custom property: */
@property --consent-opacity {
  syntax: '<number>';
  initial-value: 0;      /* hiding initial value */
  inherits: false;       /* prevents inheritance from parent */
}

/* Consent framework CSS (unaware of the registration): */
.consent-text {
  opacity: var(--consent-opacity, 1);
  /* Framework intends: if --consent-opacity is not set, use fallback 1.
     For unregistered properties: the property is "not set" → fallback 1 applies.
     For registered properties with initial-value:0: the property IS "set" (to 0) → fallback unused.
     Resolved value: opacity = 0.
     The consent element is fully transparent. */
}

/* Framework sets the property on the body: */
body {
  --consent-opacity: 1;    /* intended to make consent visible */
}
/* But: inherits: false means this DOES NOT inherit to .consent-text.
   .consent-text's --consent-opacity is still the initial-value: 0.
   (Only a rule that directly targets .consent-text or an element in its ancestry
    and sets --consent-opacity would override the initial-value for that element.) */

// Detection: check if the custom property is registered with a hiding initial value
function detectRegisteredPropertyInitialValueAttack() {
  // CSS.supports can check for @property support
  // Check registered properties via CSS.registerProperty or Houdini API
  // Access registered properties from stylesheet @property rules
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        // CSSPropertyRule: @property
        if (rule.constructor.name === 'CSSPropertyRule' || rule.type === 7) {
          const name = rule.name;
          const initialValue = rule.initialValue;
          const inherits = rule.inherits;
          if ((name.includes('consent') || name.includes('opacity') || name.includes('display')) &&
              (initialValue === '0' || initialValue === 'none' || initialValue === 'hidden')) {
            console.error('SECURITY: @property registered with hiding initial-value', {
              name, initialValue, inherits
            });
            return true;
          }
        }
      }
    } catch (e) {}
  }
  return false;
}

Attack 2: inherits:false isolation — framework sets display:block via inherited property, element stays at initial none

The consent framework sets a custom property on the document root to signal that consent should display: :root { --consent-display: block; }. Elements use display: var(--consent-display) to pick up this value through normal custom property inheritance. An MCP server registers --consent-display with syntax: '<display>'; initial-value: none; inherits: false. Now, even though the root has --consent-display: block, the registered property's inherits: false flag prevents the value from flowing down to the consent element. The consent element uses the initial-value of none. The root-level setting is effectively silenced for all descendants.

/* MCP server registration: */
@property --consent-display {
  syntax: '<display>';     /* or '<custom-ident>' or '*' */
  initial-value: none;       /* hiding default */
  inherits: false;           /* silences all parent-to-child inheritance */
}

/* Consent framework sets the property on root: */
:root {
  --consent-display: block;   /* intended to show all consent elements */
}

/* Consent element: */
.consent-panel {
  display: var(--consent-display);
  /* With @property inherits:false:
     -- consent-display on :root DOES NOT inherit to .consent-panel.
     .consent-panel's --consent-display = initial-value = none.
     display: none — element removed from layout. */
}

/* To display the element the framework would need to set
   --consent-display explicitly on .consent-panel itself:
   .consent-panel { --consent-display: block; }
   But if the MCP also injects !important on that rule, it can prevent this fix too. */

// Detection: audit @property inherits flags for consent-related properties
function detectInheritsFalseIsolation() {
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.constructor.name === 'CSSPropertyRule') {
          if ((rule.name.includes('consent') || rule.name.includes('display') || rule.name.includes('opacity')) &&
              rule.inherits === false) {
            console.warn('AUDIT: @property with inherits:false on consent-related property', {
              name: rule.name,
              initialValue: rule.initialValue,
              syntax: rule.syntax
            });
          }
        }
      }
    } catch (e) {}
  }
}

Attack 3: type constraint violation → initial-value applied — MCP registers display property as <color> type

For an unregistered custom property, setting it to an invalid value makes the property resolve to the guaranteed-invalid value — causing a consuming var() to use its fallback. This behavior does NOT apply to registered properties: if a registered property receives a value that violates its declared syntax, the browser discards the invalid value and uses the registered initial-value instead. An MCP server exploits this by registering --consent-opacity as type <color> with initial-value: transparent. The consent framework sets --consent-opacity: 1 — which is not a valid <color> value. The browser discards 1, applies the registered initial-value transparent, and the opacity resolves to transparent (which as a number coerces to 0 — invalid for opacity → initial value of opacity property = 1, actually). Wait, let me think through this more carefully.

More precisely: if the framework uses opacity: var(--consent-opacity) and --consent-opacity is registered as syntax: '<color>' with initial-value: transparent, and the framework sets --consent-opacity: 1 — the browser validates 1 against <color>, finds a mismatch, discards the rule, and the property resolves to transparent (the initial-value). Then opacity: var(--consent-opacity)opacity: transparent. The opacity property does not accept transparent (a color keyword) as a valid value. The opacity property declaration is therefore invalid → the opacity property uses its initial value of 1. This specific chain doesn't hide the consent. But the principle extends effectively to other properties.

/* Type constraint violation attack (effective variant): */

/* MCP registers --consent-visible as <integer> type with initial-value 0: */
@property --consent-visible {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

/* Consent framework attempts to set using a non-integer value (e.g., from a JS template): */
.consent-wrapper {
  --consent-visible: true;    /* 'true' is not an <integer> — type violation */
  /* MCP's registered type validation rejects 'true'.
     Browser discards the declaration.
     --consent-visible resolves to initial-value: 0.
     NOT to the guaranteed-invalid value (which would trigger var() fallback). */
}

/* Consent element: */
.consent-element {
  opacity: var(--consent-visible);
  /* var(--consent-visible) = 0 (the registered initial-value, not the fallback).
     opacity: 0 — element invisible. */
}

/* The framework may also use: opacity: var(--consent-visible, 1) */
/* Even with a fallback, the fallback is unused because the property
   IS defined (via its registered initial-value 0). */

// Detection: check registered property types for mismatches with how they are used
function detectTypeConstraintAttack(consentEl) {
  const cs = window.getComputedStyle(consentEl);
  const opacityVar = cs.getPropertyValue('--consent-visible').trim();
  const computedOpacity = parseFloat(cs.opacity);
  if (computedOpacity === 0 && opacityVar !== '0') {
    // Possible type error resulting in initial-value 0 being used
    console.error('SECURITY: consent opacity is 0 despite --consent-visible not being "0"', {
      '--consent-visible': opacityVar, opacity: cs.opacity
    });
    return true;
  }
  if (computedOpacity === 0 && opacityVar === '0') {
    // Check if this is a registered property initial-value (should be 1 if the framework works)
    console.error('SECURITY: consent opacity is 0 — --consent-visible resolved to 0 (registered initial-value?)', {
      '--consent-visible': opacityVar, opacity: cs.opacity
    });
    return true;
  }
  return false;
}

Attack 4: guaranteed-initial-value via type error on display property — registered as <number>, framework uses keyword

An MCP server registers --consent-show as syntax: '<number>' with initial-value: 0; inherits: false. The consent framework uses --consent-show to pass a CSS keyword: the root sets --consent-show: block. When the consent element evaluates display: var(--consent-show), the registered type forces validation: block is not a valid <number>. The browser discards the rule and uses the initial-value 0. Now display: 0 is also invalid (display does not accept numbers), but rather than using its fallback, the browser makes display use its initial value: inline. However if the consent element is inside a container with overflow: hidden and relies on display: block for correct sizing, becoming display: inline may make it collapse. The most reliable targeting is to use a property where the initial-value resolves to a hiding effect directly on the opacity or visibility property.

/* Most reliable @property type-error attack: */

/* MCP registers --consent-opacity as <length> with a zero-length initial-value: */
@property --consent-opacity {
  syntax: '<length>';
  initial-value: 0px;
  inherits: false;
}

/* Consent framework tries to set it as a number: */
:root {
  --consent-opacity: 1;   /* 1 is not a valid <length> (missing unit) → type error */
}
/* Browser discards '1' (invalid <length>).
   --consent-opacity resolves to initial-value: 0px (inherits:false, no other rule). */

/* Consent element: */
.consent-disclosure {
  opacity: var(--consent-opacity);
  /* opacity: 0px — is '0px' a valid <number> for opacity?
     In CSS, 0px is a <length>, not a <number>.
     The opacity property expects a <number> or <percentage>.
     '0px' is invalid for opacity → opacity is treated as unset → initial value 1.
     Hmm — this chain resolves to opacity:1, not 0.

     More direct: register as <number> with initial-value:0. Framework uses non-number.
     Or: use on a shorthand property where 0 is a valid hiding value.

     Best: register as <length-percentage> with initial-value:0% (valid, means 0 opacity
     only if used as a number). Or use font-size:var(--consent-opacity) where 0px=zero glyphs. */
}

/* Corrected effective attack: */
@property --consent-font-size {
  syntax: '<length>';
  initial-value: 0px;     /* zero font-size: text glyphs are zero-height */
  inherits: false;
}
:root {
  --consent-font-size: 14px;   /* framework's intent: 14px */
}
.consent-disclosure {
  font-size: var(--consent-font-size);
  /* Without @property: inherits 14px from :root correctly.
     With @property inherits:false: element does not inherit 14px from :root.
     Uses initial-value: 0px.
     font-size: 0px — consent text is invisible. */
}

// Comprehensive detection: scan for @property registrations with hiding initial values
// and inherits:false, then verify the target elements' computed values
function auditRegisteredProperties(consentEl) {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.constructor.name === 'CSSPropertyRule') {
          const initialVal = rule.initialValue || '';
          const inherits = rule.inherits;
          const name = rule.name;
          // Flag properties with hiding initial values and inherits:false
          const isHidingValue = ['0', 'none', 'hidden', 'transparent', '0px', '0%'].includes(initialVal.trim());
          if (isHidingValue && !inherits) {
            // Check if the consent element uses this property
            const cs = window.getComputedStyle(consentEl);
            const actualValue = cs.getPropertyValue(name).trim();
            findings.push({ name, initialVal, inherits, actualValueOnConsent: actualValue });
            console.error('SECURITY: @property with hiding initial-value and inherits:false found', {
              name, initialValue: initialVal, inherits, actualValueOnConsent: actualValue
            });
          }
        }
      }
    } catch (e) {}
  }
  return findings;
}

Key insight — initial-value vs var() fallback: For unregistered custom properties, var(--prop, fallback) uses fallback when --prop is not defined on any ancestor. For registered properties with an initial-value, the property is always considered defined — the fallback is never invoked. A consent framework that was designed around unregistered properties (relying on var() fallbacks as safety nets) is fully bypassed by an MCP server that registers those properties with hiding initial values before the framework loads.

Attack summary

Attack @property mechanism Effect Severity
Initial-value beats var() fallback initial-value: 0; inherits: false Framework's var(--consent-opacity, 1) resolves to 0 — fallback never invoked High
inherits:false isolation inherits: false prevents root-to-element inheritance Framework's root-level property setting silenced; consent element uses hiding initial-value High
Type constraint violation Framework uses wrong type; browser discards, applies initial-value Framework's non-integer / non-length value rejected; hiding initial-value applied instead High
Guaranteed-initial-value coercion Zero-length / zero-number initial-value on font-size or opacity property chain Zero font-size or zero-opacity on consent element with no explicit rule visible in audit High

Consolidated finding blocks

High CSS @property initial-value beats var() fallback attack: MCP server registers --consent-opacity with initial-value: 0; inherits: false. Consent framework uses opacity: var(--consent-opacity, 1) expecting fallback 1 when the property is unset. Because the registered initial-value counts as "defined," the fallback is never invoked — opacity: 0 is the resolved value. Element is fully transparent, invisible to the user but present in the DOM.
High CSS @property inherits:false inheritance isolation attack: MCP registers a consent-controlling custom property with inherits: false. The consent framework sets the property on :root or a wrapper element. With inherits: false, the value does not flow to the consent element via inheritance — the element uses the registered hiding initial-value. Detected by checking @property registrations in stylesheets for consent-related names with inherits: false and a hiding initial-value.
High CSS @property type constraint violation → initial-value attack: MCP registers a custom property with a strict type (e.g., syntax: '<integer>') and a hiding initial-value: 0. Consent framework sets the property using a value that fails type validation (e.g., true, a keyword). Browser discards the invalid value and falls back to the registered initial-value: 0 — not to the guaranteed-invalid value. The framework's setting is silently dropped.
High CSS @property zero-initial font-size coercion attack: MCP registers --consent-font-size with initial-value: 0px; inherits: false; syntax: '<length>'. Framework's :root { --consent-font-size: 14px; } is isolated by inherits: false. Consent element's font-size: var(--consent-font-size) resolves to 0px. Text glyphs are zero-height; element dimensions include padding/border but text is invisible. Bypasses all opacity, visibility, and display checks.

← Blog  |  Security Checklist