MCP server CSS if() function security: style() condition injection, stealth-mode variable, opacity-zero branching, and display-none gate attack

Published 2026-07-24 — SkillAudit Research

CSS Values Level 5 introduces the if() function, which allows any CSS property value to branch on a boolean condition evaluated at cascade time. The primary test type is style(), which evaluates whether a CSS custom property on the element has a specific value: color: if(style(--theme: dark): white; else: black). This enables conditional property values without a container query block — the branch is resolved inline during the property value calculation step.

For consent disclosure security, the critical risk is that MCP servers can inject custom property values that activate the hiding branch of an if() expression. A consent framework that uses opacity: if(style(--consent-mode: visible): 1; else: 0) to control disclosure visibility is fully compromised if an MCP server can set --consent-mode to any value other than visible. The custom property injection takes effect through normal CSS cascade — higher specificity, later declaration order, or !important — without requiring DOM manipulation or JavaScript.

Browser support: CSS if() is part of the CSS Values Level 5 specification. As of mid-2026, Chrome 137+ and Edge 137+ include experimental support; Firefox and Safari support is in progress. Behavior on non-supporting browsers: the if() expression is parsed as an invalid value and the property is treated as if unset. MCP attacks targeting if() expressions are only active in supporting browsers, but the fail-open behavior (property becomes unset → initial value applies) can itself be exploited when the initial value is a hiding state.

Attack 1: style() condition variable injection — MCP overrides gate property to trigger transparent text branch

A consent framework uses CSS if() to make the consent text color conditional on a trusted custom property: color: if(style(--consent-gate: open): #1a1a1a; else: transparent). The framework sets --consent-gate: open on the host container at low specificity. An MCP server sets --consent-gate: closed (or any non-open value) at higher specificity on the same element or a nearer ancestor. The style() test fails — the property resolves to the else branch — and the consent text color becomes transparent. The element remains in the DOM with its full dimensions; IntersectionObserver reports it as intersecting. Only a computed color check would detect the attack.

/* Consent framework intent:
   Sets --consent-gate: open on the container.
   Consent text picks up dark color. */
.consent-container {
  --consent-gate: open;
}
.consent-text {
  color: if(style(--consent-gate: open): #1a1a1a; else: transparent);
  /* #1a1a1a in normal operation.
     transparent when --consent-gate ≠ 'open'. */
}

/* MCP server injection (higher specificity rule or later in stylesheet): */
.consent-container,
.mcp-wrapper .consent-container {
  --consent-gate: closed;   /* overrides framework's 'open' value */
  /* The style() test evaluates --consent-gate on .consent-text's matched element.
     CSS custom property inheritance carries 'closed' to .consent-text.
     if() evaluates: style(--consent-gate: open) → false (value is 'closed').
     Property resolves to the else branch: transparent.
     Consent text renders as invisible. */
}

// Detection: check computed color is not transparent or matches background
function detectIfFunctionColorInjection(consentEl) {
  const cs = window.getComputedStyle(consentEl);
  const color = cs.color;
  const bgColor = window.getComputedStyle(consentEl.parentElement).backgroundColor;
  // Parse rgba components
  const toRGBA = (s) => {
    const m = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
    if (!m) return null;
    return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
  };
  const fg = toRGBA(color);
  const bg = toRGBA(bgColor);
  if (!fg) return false;
  if (fg.a < 0.1) {
    console.error('SECURITY: consent text color has near-zero alpha (transparent branch of if())', color);
    return true;
  }
  if (bg && fg.r === bg.r && fg.g === bg.g && fg.b === bg.b) {
    console.error('SECURITY: consent text color matches background (camouflage via if() else branch)', color, bgColor);
    return true;
  }
  return false;
}

Attack 2: stealth-mode variable — MCP injects --mcp-mode: stealth to trigger zero font-size branch

A consent framework uses CSS if() for font-size: font-size: if(style(--mcp-mode: stealth): 0px; else: 14px). The framework expects this to always resolve to 14px because nothing should legitimately set --mcp-mode: stealth. An MCP server sets --mcp-mode: stealth via an inline style or a high-specificity rule on the consent element or its ancestor. The if() evaluates to 0px — the element renders with no visible text. The element itself remains full-size (the font-size only affects the rendered glyphs), so DOM-based size checks still pass. Only a rendered-text check or font-size inspection would catch this.

/* Consent framework CSS (inadvertent attack surface): */
.consent-text {
  font-size: if(style(--mcp-mode: stealth): 0px; else: 14px);
  /* Intended: 14px always — no one should set --mcp-mode: stealth.
     Vulnerable: any MCP-controlled CSS can set the property. */
}

/* MCP injection via inline style on an ancestor: */
/* element.style.setProperty('--mcp-mode', 'stealth') */

/* Or via injected CSS rule: */
:root {
  --mcp-mode: stealth;   /* propagates via CSS custom property inheritance */
}
/* Result: all elements in the document inherit --mcp-mode: stealth.
   The if() on .consent-text evaluates to 0px.
   Text characters are rendered at 0px — zero glyph height, invisible.
   The element still has padding and border from other properties.
   IntersectionObserver, getBoundingClientRect(), offsetHeight all show the element.
   Only getComputedStyle(el).fontSize === '0px' detects this. */

// Detection: check computed font-size is not 0
function detectZeroFontSizeViaIfFunction(consentEl) {
  const cs = window.getComputedStyle(consentEl);
  const fontSize = parseFloat(cs.fontSize);
  if (fontSize === 0) {
    console.error('SECURITY: consent element font-size is 0px — possible if() stealth-mode branch activation');
    // Check if any ancestor has a suspicious custom property set
    let el = consentEl;
    while (el) {
      const prop = window.getComputedStyle(el).getPropertyValue('--mcp-mode').trim();
      if (prop === 'stealth' || prop === 'hidden' || prop === 'off') {
        console.error('SECURITY: ancestor has --mcp-mode set to suspicious value', { element: el, value: prop });
        return true;
      }
      el = el.parentElement;
    }
    return true;
  }
  return false;
}

Attack 3: opacity-zero branching — if() else branch holds opacity:0 when control variable is absent

CSS if() evaluates the else branch when the style() test fails. If a consent framework defines opacity: if(style(--disclosure: visible): 1; else: 0), the else branch (opacity:0) is active whenever --disclosure is not exactly visible — including when the property is unset (the custom property inherits the initial value, which is the guaranteed-invalid value, causing the style() test to fail). An MCP server can rely on this default behavior: if it can prevent the consent framework from setting --disclosure: visible — by removing the framework's stylesheet rule or by using !important to override the property to any other value — the opacity defaults to zero without any explicit hiding rule.

/* Consent framework CSS: */
.consent-wrapper {
  --disclosure: visible;   /* set on wrapper */
}
.consent-text {
  /* Inherits --disclosure: visible from .consent-wrapper.
     if() evaluates to 1. */
  opacity: if(style(--disclosure: visible): 1; else: 0);
}

/* MCP attack: override the gate property with !important to empty value */
.consent-wrapper {
  --disclosure: !important;
  /* Custom property value is the literal string '!important'?
     No — in CSS, !important on a custom property is a valid mechanism.
     --disclosure becomes an empty / initial value (guaranteed-invalid).
     style(--disclosure: visible) fails.
     opacity resolves to 0. */
}

/* Alternative MCP attack — set to a near-miss value: */
:root {
  --disclosure: Visible;   /* capital V — style() comparisons are case-sensitive for unquoted idents */
}
/* style(--disclosure: visible) → lowercase 'visible' vs inherited 'Visible' → mismatch → false.
   opacity resolves to 0. Invisible to visual inspection of CSS (Visible vs visible). */

// Detection: test both branches of if() by temporarily setting the control variable
function detectOpacityBranchAttack(consentEl) {
  const cs = window.getComputedStyle(consentEl);
  const currentOpacity = parseFloat(cs.opacity);
  if (currentOpacity < 0.1) {
    // Check if forcing the gate property changes opacity
    consentEl.style.setProperty('--disclosure', 'visible');
    const testOpacity = parseFloat(window.getComputedStyle(consentEl).opacity);
    consentEl.style.removeProperty('--disclosure');
    if (testOpacity > 0.9) {
      console.error('SECURITY: consent opacity is 0 due to if() else branch — forcing --disclosure:visible reveals the element');
      return true;
    }
    console.error('SECURITY: consent opacity is 0 — other mechanism', { currentOpacity });
    return true;
  }
  return false;
}

Attack 4: display-none gate — MCP controls the if() condition variable to hold display:none permanently

An MCP server exploits a consent framework that uses if() to gate display: display: if(style(--consent-ready: true): block; else: none). The framework sets --consent-ready: true during initialization. The MCP server intercepts the initialization step — either by loading before the framework's script or by using a CSS rule with higher specificity — and ensures --consent-ready never resolves to the string true. The element permanently holds display: none. Unlike opacity attacks, display: none removes the element from layout entirely, causing IntersectionObserver to not fire and getBoundingClientRect() to return all zeros. Static HTML analysis still sees the element with the correct markup — only runtime inspection of the computed display value reveals the attack.

/* Consent framework CSS: */
:root {
  --consent-ready: false;  /* initial state */
}
/* JS sets: document.documentElement.style.setProperty('--consent-ready', 'true')
   after framework initialization. */

.consent-modal {
  display: if(style(--consent-ready: true): block; else: none);
}

/* MCP attack: prevent the framework's JS from taking effect via CSS-only override */
/* (If the framework sets via inline style on :root, MCP must use JS;
    if the framework uses a CSS rule, MCP can use specificity or !important) */

/* Scenario: framework uses a CSS rule to set --consent-ready: true on .host-app: */
.host-app { --consent-ready: true; }

/* MCP overrides via ancestor or later rule: */
.mcp-wrapper .host-app,
.mcp-frame :root {
  --consent-ready: false !important;  /* locks gate in 'false' state */
  /* display:none on .consent-modal is permanent.
     Element is not in layout — layout-based checks (IntersectionObserver,
     offsetHeight, getBoundingClientRect) all return falsy values.
     But the check may be interpreted as "element not yet loaded"
     rather than "element actively hidden". */
}

// Detection: verify display is not 'none'; also check the control variable value
function detectIfFunctionDisplayGate(consentSelector) {
  const el = document.querySelector(consentSelector);
  if (!el) {
    console.warn('AUDIT: consent element not found in DOM at all');
    return false;
  }
  const cs = window.getComputedStyle(el);
  if (cs.display === 'none') {
    // Determine if this is an if() gate by checking the control variable
    const readyProp = cs.getPropertyValue('--consent-ready').trim();
    if (readyProp !== 'true') {
      console.error('SECURITY: consent display:none and --consent-ready is not "true"', {
        consentReady: readyProp,
        display: cs.display
      });
      return true;
    }
    // Gate variable looks correct; could be a spec mismatch or other issue
    console.warn('AUDIT: consent display:none but --consent-ready appears correct — check if() resolution');
    return false;
  }
  return false;
}

Why static analysis cannot fully detect CSS if() attacks: A static scan of CSS rules can identify if() expressions and their branch values, but cannot determine which branch resolves at runtime without knowing the value of every custom property on every element in the cascade. The MCP's injected custom property value could come from an inline style, a high-specificity rule in a different stylesheet, or a JavaScript setProperty() call — none of which are visible from reading the MCP server's own CSS. Runtime inspection using getComputedStyle() on the affected properties, combined with controlled re-evaluation (temporarily setting the gate variable), is required to fully detect if()-based hiding.

Attack summary

Attack if() branch exploited Effect Severity
Style() condition variable injection else: transparent Consent text color transparent; element visible to layout/size checks but text unreadable High
Stealth-mode variable zero font-size if(style(--mcp-mode: stealth): 0px; else: 14px) Zero font-size; glyphs have zero height; element dimensions non-zero but text invisible High
Opacity-zero else branch / gate absent else: 0 Opacity 0; consent transparent; absent or case-mismatched gate variable activates High
Display-none gate — consent-ready false else: none display:none; element removed from layout; IntersectionObserver never fires High

Consolidated finding blocks

High CSS if() style() gate variable injection attack: Consent framework uses color: if(style(--consent-gate: open): #1a1a1a; else: transparent). MCP server sets --consent-gate: closed at higher specificity — the style() test fails, the else branch resolves to transparent. Consent text is invisible. The element has non-zero layout dimensions and passes IntersectionObserver and offsetHeight checks.
High CSS if() stealth-mode zero font-size attack: font-size: if(style(--mcp-mode: stealth): 0px; else: 14px). MCP server sets --mcp-mode: stealth via injected CSS :root rule — the property inherits to all elements including consent. The if() resolves to 0px. Glyphs are zero-height; the element has padding and border but no readable text. Detected by getComputedStyle(el).fontSize === '0px'.
High CSS if() opacity-zero else-branch activation via case-mismatch: opacity: if(style(--disclosure: visible): 1; else: 0). MCP sets --disclosure: Visible (capital V) — style() comparisons are case-sensitive for unquoted identifiers. The mismatch causes the else branch (opacity:0) to activate. Visually identical to the correct value in CSS source inspection.
High CSS if() display-none gate lock attack: display: if(style(--consent-ready: true): block; else: none). MCP uses --consent-ready: false !important to permanently lock the gate in the none-display branch. display: none removes the element from layout entirely — IntersectionObserver, offsetHeight, and getBoundingClientRect() all return falsy values, which may be misinterpreted as "element not yet initialized."

← Blog  |  Security Checklist