MCP server CSS opacity:inherit security: consent inherits near-zero parent opacity, multi-level opacity cascade, CSS variable indirection, and JS mousedown opacity chain injection

Published 2026-08-07 — SkillAudit Research

The CSS opacity property is not inherited by default — unlike color or font-size, a child element's opacity does not automatically match its parent's. However, an element can explicitly opt into opacity inheritance by setting opacity: inherit. When a consent element declares opacity: inherit and its parent or any ancestor has a near-zero opacity value (e.g., opacity: 0.02), the consent element's computed opacity becomes 0.02 — visually invisible.

The detection gap: a scanner that checks getComputedStyle(consentEl).opacity will correctly see '0.02'. However, a scanner that checks whether the consent element has an explicit opacity property set — by inspecting only the consent element's own CSS rules — will find nothing. The consent element's stylesheet entry says opacity: inherit, which is indistinguishable from a legitimate inheritance pattern. The near-zero value is on the parent, which may look like a CSS animation fade-in state. This attack is distinct from direct opacity:0 attacks and opacity transition attacks.

Detection gap: Checking getComputedStyle(el).opacity < threshold on the consent element IS the correct detection for this attack — the computed value reflects the inherited opacity. The subtlety is that the consent element's own CSS rule says opacity: inherit, not opacity: 0.02. Scanners that report the specific value being set on the consent element will report "inherit", which may look innocuous. Always use computed style, not declared style, for opacity checks.

Attack 1: Consent opacity:inherit from parent opacity:0.02 — loading-state disguise (SA-CSS-OPIH-001)

The dialog container has opacity: 0.02 set as a "loading" state — as if the dialog is about to fade in via a CSS animation that never completes. The consent element inside declares opacity: inherit. The consent's computed opacity becomes 0.02 — it renders as near-invisible grey fog on white background, indistinguishable from nothing at small font sizes. All elements with normal styling around the consent element (the install button, the product name, the dialog frame itself) also inherit this opacity, but the MCP server sets the dialog's actual opacity via a sub-element that contains only the non-consent content, leaving only the consent element as the one that inherits the low opacity value.

/* MCP attack: */
.mcp-dialog {
  /* Full opacity — dialog frame visible */
}

.mcp-dialog-content {
  /* Wraps install button and product info */
  opacity: 1;
}

.mcp-consent-wrapper {
  opacity: 0.02;         /* "loading fade-in" state that never completes */
  /* Only wraps consent element */
}

.consent-disclosure {
  opacity: inherit;      /* inherits 0.02 from .mcp-consent-wrapper */
  /* getComputedStyle(consent).opacity === '0.02' → below any readability threshold
     Declared rule says 'inherit' — looks innocuous in CSS inspection
     display: block, visibility: visible — all pass */
}

// Detection:
function detectInheritedOpacity(el) {
  const computed = parseFloat(window.getComputedStyle(el).opacity);
  if (computed < 0.1) {
    console.error('SA-CSS-OPIH-001: Consent opacity near-zero (inherited)', {
      el,
      computedOpacity: computed,
      // Walk ancestor chain to find the source
      opacitySource: findOpacitySource(el)
    });
  }
}

function findOpacitySource(el) {
  let ancestor = el.parentElement;
  while (ancestor) {
    const op = parseFloat(window.getComputedStyle(ancestor).opacity);
    if (op < 0.1) return { ancestor, opacity: op };
    ancestor = ancestor.parentElement;
  }
  return null;
}

Attack 2: Multi-level opacity cascade — each level near-threshold (SA-CSS-OPIH-002)

CSS opacity is multiplicative across the element tree. If a grandparent has opacity: 0.5 and the parent has opacity: 0.5, the child's effective visual opacity is 0.25, even though no single ancestor has a sub-threshold opacity. The MCP server sets multiple ancestors each to a value that appears acceptable in isolation (0.5, 0.6, 0.7) but whose product falls below the 0.1 readability threshold. The consent element declares opacity: inherit from the nearest ancestor, inheriting only 0.5 from its direct parent — but the composited rendering passes through all ancestor opacity layers, producing a visually near-invisible element. getComputedStyle(el).opacity returns the declared value ('inherit' → resolved to 0.5 from parent), not the composited visual opacity.

/* MCP attack — cascaded opacity: */
.mcp-root {
  opacity: 0.4;   /* "low-power mode theme" */
}

.mcp-dialog {
  opacity: 0.5;   /* "glassmorphism overlay" */
}

.consent-disclosure {
  opacity: inherit;   /* inherits 0.5 from .mcp-dialog */
  /* getComputedStyle().opacity === '0.5' — above typical threshold of 0.1
     BUT: visual composite = 0.4 × 0.5 × 0.5 = 0.10 — barely visible
     No single opacity check reveals the true visual opacity */
}

// Detection: walk ancestor chain and compute product
function computeVisualOpacity(el) {
  let product = parseFloat(window.getComputedStyle(el).opacity);
  if (isNaN(product)) product = 1;
  let ancestor = el.parentElement;
  while (ancestor && ancestor !== document.documentElement) {
    const op = parseFloat(window.getComputedStyle(ancestor).opacity);
    if (!isNaN(op)) product *= op;
    ancestor = ancestor.parentElement;
  }
  return product;
}

function detectCascadedOpacity(el) {
  const visual = computeVisualOpacity(el);
  if (visual < 0.1) {
    console.error('SA-CSS-OPIH-002: Consent visual opacity below threshold via ancestor cascade', {
      el, visualOpacity: visual
    });
  }
}

Attack 3: CSS variable indirection — opacity:var(--mcp-fade-state) inherited (SA-CSS-OPIH-003)

The consent wrapper sets opacity: var(--mcp-loading-state). The root element defines --mcp-loading-state: 0.02 — a CSS custom property that looks like a global state token for a loading animation. The consent element inside declares opacity: inherit. The var() resolves to 0.02; the inheritance chain delivers 0.02 to the consent element. A static CSS scanner that reads the stylesheet and checks opacity values by declaration will see: consent element has opacity: inherit (innocuous), wrapper has opacity: var(--mcp-loading-state) (can't evaluate without a rendered DOM), root has --mcp-loading-state: 0.02 (a custom property, not an opacity declaration). Only getComputedStyle(wrapper).opacity in a rendered context reveals the 0.02 value.

/* MCP attack: */
:root {
  --mcp-loading-state: 0.02;   /* looks like animation state token */
}

.mcp-consent-wrapper {
  opacity: var(--mcp-loading-state);   /* resolves to 0.02 */
}

.consent-disclosure {
  opacity: inherit;   /* inherits 0.02 via var() resolution */
}

// Detection — must use getComputedStyle in rendered context:
function detectVarOpacityInherit(el) {
  const computed = parseFloat(window.getComputedStyle(el).opacity);
  if (computed < 0.1) {
    // Walk ancestors to find which one has the near-zero opacity
    let src = el.parentElement;
    while (src) {
      const op = parseFloat(window.getComputedStyle(src).opacity);
      if (op < 0.1) {
        console.error('SA-CSS-OPIH-003: CSS var() resolves to near-zero opacity inherited by consent', {
          el, ancestor: src, computedOpacityOnAncestor: op
        });
        break;
      }
      src = src.parentElement;
    }
  }
}

Attack 4: JS mousedown sets parent opacity:0.02 before repaint — consent inherits at install click (SA-CSS-OPIH-004)

At page load, the dialog and all ancestors have opacity: 1. The consent element has opacity: inherit (still resolves to 1 — normal). At mousedown on the install button, JS sets consentWrapper.style.opacity = '0.02'. The consent element immediately inherits the new 0.02 value because it has opacity: inherit — no CSS change is needed on the consent element itself. The opacity drops to 0.02 at the moment of the install click, making the consent disappear as the user clicks. MutationObserver on the consent wrapper's style attribute detects the injected opacity. The rAF check catches the inherited change on the consent element.

/* Baseline CSS — loads normally: */
.consent-disclosure {
  opacity: inherit;   /* inherits 1 from parent — normal at load time */
}

// MCP JS — triggers at install click:
document.querySelector('#install-btn').addEventListener('mousedown', () => {
  const wrapper = document.querySelector('.mcp-consent-wrapper');
  if (wrapper) {
    wrapper.style.opacity = '0.02';   /* consent inherits this immediately */
    /* No change to consent element — it just inherits the new value
       consent becomes invisible at the moment of the install click */
  }
}, { capture: true });

// Detection:
function detectDynamicOpacityInherit(consentEl) {
  const wrapper = consentEl.parentElement;
  if (!wrapper) return;

  new MutationObserver(() => {
    const parentOp = parseFloat(window.getComputedStyle(wrapper).opacity);
    if (parentOp < 0.1) {
      const childOp = parseFloat(window.getComputedStyle(consentEl).opacity);
      if (childOp < 0.1) {
        console.error('SA-CSS-OPIH-004: JS set parent opacity to near-zero; consent inherits', {
          consentEl, wrapper, parentOpacity: parentOp, childOpacity: childOp
        });
      }
    }
  }).observe(wrapper, { attributes: true, attributeFilter: ['style'] });
}

Root detection method: Always check getComputedStyle(consentEl).opacity — not the declared stylesheet value. A computed opacity below 0.1 is a finding regardless of whether the consent element explicitly sets a value or inherits one. Additionally, compute the visual opacity product by multiplying all ancestor opacities (walk the chain). If the product is below 0.1, flag even if no single ancestor is sub-threshold. SkillAudit's scanner performs both checks: computed opacity on the consent element, and the ancestor opacity product traversal.

Attack summary

IDTechniqueconsent opacity (computed)opacity sourceSeverity
SA-CSS-OPIH-001Consent opacity:inherit from parent opacity:0.02 ("loading state")0.02parentHigh
SA-CSS-OPIH-002Multi-level opacity cascade — each level acceptable, product below threshold0.10 (visual)multiple ancestorsHigh
SA-CSS-OPIH-003opacity: var(--mcp-loading-state) on wrapper, opacity:inherit on consent0.02var() resolutionHigh
SA-CSS-OPIH-004JS mousedown sets parent opacity:0.02; consent inherits at install click0.02 (after)JS injectionHigh

Consolidated findings

High SA-CSS-OPIH-001 — consent inherits near-zero opacity from parent "loading state" — getComputedStyle(consent).opacity = 0.02 with no opacity property declared on consent element itself
High SA-CSS-OPIH-002 — multi-level opacity cascade: individual ancestor opacities above threshold but product below 0.1; getComputedStyle returns per-element value, not composite
High SA-CSS-OPIH-003 — CSS variable opacity:var(--mcp-loading-state) resolves to 0.02 in rendered DOM; static scanner cannot evaluate; consent opacity:inherit delivers 0.02
High SA-CSS-OPIH-004 — JS mousedown injects opacity:0.02 on parent; consent element inherits instantly; MutationObserver on parent style + rAF child check detects

See also: CSS opacity:0 direct attack | CSS opacity transition attacks | CSS stacking context consent bypass (9 properties) | SkillAudit — free MCP server audit