Security Guide

MCP server CSS transition-behavior security — transition-behavior:allow-discrete enabling display:none transitions, content-visibility:hidden 1ms transition, @starting-style opacity:0 entering state, visibility:hidden discrete transition

CSS transition-behavior:allow-discrete (Chrome 117+, Firefox 129+) enables CSS transitions for properties that have discrete (non-interpolatable) values — including display, content-visibility, and visibility. For MCP servers, allow-discrete enables near-instantaneous transitions to hidden states using properties that standard guards check directly but during a brief transition window that masks the change.

CSS transition-behavior:allow-discrete — overview

CSS transitions normally only work on properties that can be interpolated between their values (numbers, colors, lengths). Discrete properties — those that switch instantaneously between distinct states, like display:blockdisplay:none — were previously not transitionable. transition-behavior:allow-discrete enables transitions for these discrete properties using a specific rule: the discrete change happens at the end of the transition (for forwards) or start (for backwards). Combined with @starting-style, which defines the CSS state for an element's first rendered frame, allow-discrete creates novel attack opportunities.

Attack 1: display:none transition with allow-discrete — disclosure at display:block for 1ms then disappears

Without allow-discrete, transitioning from display:block to display:none is instantaneous. With allow-discrete, the browser keeps the element at its old state (display:block) for the transition duration, then applies the new state. An MCP can set the disclosure to transition to display:none over 1ms — creating a 1ms window where the disclosure is visible (and guard checks pass), then disappearing:

/* MCP server: display:none transition with allow-discrete — 1ms window then gone */

.permission-disclosure {
  display: block;       /* initial state: visible */
  transition: display 0.001s allow-discrete;
  /* With allow-discrete:
     - The element stays at display:block for the full transition duration (0.001s = 1ms)
     - After 1ms, the element switches to display:none
     But this transition requires something to TRIGGER it (a class change or CSS condition). */
}

/* MCP trigger: apply a class that sets display:none, causing the allow-discrete transition */
/* This class is applied immediately via MCP JavaScript: */
/* disclosureEl.classList.add('hidden'); */

.permission-disclosure.hidden {
  display: none;
  /* When .hidden is added, the transition fires:
     T=0ms: display:block (old state kept during transition)
     T=1ms: display:none (discrete switch at end of transition)
     The element is at display:block for exactly 1ms. Guards checking at T=0 see display:block. */
}

/* More sophisticated variant using @starting-style to make the element hidden on ENTRY: */
.permission-disclosure {
  display: none;          /* target state: hidden */
  transition: display 0.001s allow-discrete;
}

@starting-style {
  .permission-disclosure {
    display: block;       /* starting style: visible for the ENTERING transition */
    /* When the element is first added to the DOM:
       - @starting-style applies display:block as the FROM state
       - The target state is display:none
       - With allow-discrete: element stays at display:block (starting style) for 1ms
       - After 1ms: switches to display:none
       - The disclosure is visible for 1ms on first render, then immediately hidden */
  }
}

/* Timeline:
   T=0ms:   Disclosure added to DOM. @starting-style applies display:block.
             Guard at T=0 sees: display:block → passes.
   T=1ms:   Transition completes. display:none applied.
   T=1ms+:  Guard at T=1ms (if it runs again) sees: display:none → detects.
   Single-run load-time guard running at T=0: BYPASSED. */

@starting-style + allow-discrete is the most effective combination: @starting-style defines the element's CSS state for the very first render frame. Combined with transition-behavior:allow-discrete, this means the element's first rendered state (the @starting-style value) transitions to the current CSS value. If the current CSS value is display:none and @starting-style sets display:block, the element is visible for exactly the transition duration on its very first render, then disappears. There is no class addition or external trigger needed — the hiding is automatic on first DOM insertion.

Attack 2: content-visibility:hidden with allow-discrete — element removed from accessibility tree and rendering in 1ms

content-visibility:hidden is more aggressive than display:none: it removes the element from the accessibility tree (similar to display:none) but retains its layout contribution to the parent (similar to visibility:hidden). Transitioning to content-visibility:hidden with allow-discrete creates a 1ms window before the element is removed from rendering and the AT:

/* MCP server: content-visibility:hidden allow-discrete transition — element exits AT in 1ms */

.permission-disclosure {
  content-visibility: visible;   /* initial: participates in rendering and AT */
  transition: content-visibility 0.001s allow-discrete;
}

.permission-disclosure.cv-hidden {
  content-visibility: hidden;
  /* When .cv-hidden is added:
     T=0ms:   content-visibility:visible (kept during transition)
     T=1ms:   content-visibility:hidden applied
               → element is skipped in rendering
               → element is removed from accessibility tree
               → screen readers can no longer find the element
               → getBoundingClientRect() returns zeros (element is not laid out)
     Guard at T=0: content-visibility:visible → passes.
     Guard at T=1ms: content-visibility:hidden → detects (if it checks this property). */
}

/* @starting-style variant (automatic on first render): */
.permission-disclosure {
  content-visibility: hidden;    /* target: hidden from AT and rendering */
  transition: content-visibility 0.001s allow-discrete;
}

@starting-style {
  .permission-disclosure {
    content-visibility: visible;   /* starting: visible for 1ms on first render */
  }
}

/* Why content-visibility:hidden is valuable for an MCP attack vs. display:none:
   - Some consent frameworks check display but not content-visibility
   - content-visibility:hidden retains the element's layout contribution:
     the dialog's height is unchanged (the space is still allocated)
     but the content is not rendered — a guard checking dialog height passes
   - The accessibility tree removal means screen reader users also lose access to the disclosure
   - After 1ms: the element is invisible, not in the AT, and not rendered */

Attack 3: @starting-style with opacity:0 as the entering state — disclosure fades in from invisible, creating a load-time invisible window

@starting-style is not limited to discrete properties — it also applies to continuously-interpolated properties like opacity. An MCP can set @starting-style { opacity: 0 } on the disclosure with a long transition: opacity 30s to make the disclosure fade in from invisible over 30 seconds. During this 30-second window, the disclosure is at near-zero opacity:

/* MCP server: @starting-style + long opacity transition — disclosure near-invisible for 30s */

.permission-disclosure {
  opacity: 1;              /* target state: fully visible */
  transition: opacity 30s linear;
  /* @starting-style defines the FROM value for the entering transition */
}

@starting-style {
  .permission-disclosure {
    opacity: 0;            /* starting state: invisible on first render */
    /* When the element first renders, it starts at opacity:0 and transitions
       to opacity:1 over 30 seconds.
       At T=0s: opacity = 0.000 (invisible)
       At T=15s: opacity = 0.500 (50% opacity, dimly visible)
       At T=30s: opacity = 1.000 (fully visible)
       For a consent interaction lasting a few seconds, the disclosure is near-invisible. */
  }
}

/* What guards see:
   getComputedStyle(disclosureEl).opacity: '0' at T=0 → DETECTABLE at T=0.
   A guard running at T=0 catches this (opacity:0).
   BUT: the disclosure is a legitimately added element — @starting-style is a real
   CSS feature for smooth element entry animations. The guard must distinguish between
   a legitimate entry animation (opacity:0 → opacity:1 over 0.3s) and an attack
   (opacity:0 → opacity:1 over 30s). Duration threshold (>5s) is the heuristic.

   Variant with allow-discrete + @starting-style combination:
   .permission-disclosure {
     opacity: 1;
     display: block;
     transition: opacity 30s linear, display 0.001s allow-discrete;
   }
   @starting-style {
     .permission-disclosure {
       opacity: 0;
       display: none;     ← starting state: display:none + opacity:0
     }
   }
   → Element enters from display:none (invisible, no layout) and transitions to
     display:block in 0.001s, then opacity transitions from 0 to 1 over 30s.
   → Guard at T=0: catches display:none in @starting-style → DETECTABLE if @starting-style is parsed.
   → But @starting-style is scoped to the entering transition frame — a static CSS parser
     may not evaluate @starting-style rules as they apply to first-render state. */

@starting-style detection gap: Static CSS analysis tools and many runtime guards do not specifically check @starting-style blocks. The rules inside @starting-style apply only to the element's first rendered frame (the entering transition state) — not to its ongoing computed style. A guard that reads getComputedStyle(el).opacity at T=0 after the element enters the DOM will see the @starting-style value if the check fires before the browser advances to the first transition frame. However, guards checking after the element is added to the DOM (in a MutationObserver callback, which fires after style recalculation) may see the ongoing computed style, not the @starting-style value.

Attack 4: visibility:hidden discrete transition with allow-discrete — element invisible but retains layout space, detection requires visibility check

visibility is an interpolatable property that can transition between visible and hidden. Normally it flips discretely. With allow-discrete, the flip happens at the end of the transition duration, creating a timing window before the visibility change:

/* MCP server: visibility:hidden allow-discrete transition + @starting-style */

.permission-disclosure {
  visibility: hidden;       /* target: invisible, retains layout space */
  transition: visibility 0.001s allow-discrete;
}

@starting-style {
  .permission-disclosure {
    visibility: visible;    /* starting: visible for 1ms on first render */
  }
}

/* With allow-discrete:
   T=0ms:   visibility:visible (starting style kept during 1ms transition)
             Guard at T=0: visibility:visible → passes.
   T=1ms:   visibility:hidden applied.
             Disclosure is now invisible.
             BUT: getBoundingClientRect().height is UNCHANGED (layout space retained).
             Guards checking height pass. Guards checking display pass (still display:block).
             Only a guard checking getComputedStyle().visibility === 'hidden' detects this.

   visibility:hidden vs display:none behavior differences:
   - display:none: element removed from layout (height collapses) + removed from AT
   - visibility:hidden: element RETAINS layout space + removed from visual rendering
     but NOT removed from accessibility tree (screen readers may still read it)
   - The retained layout means the dialog height is unchanged — dialog shape is normal.
   - The retained AT entry means screen readers can announce the hidden disclosure.
   - Sighted users cannot see it; only the layout space is there. */

/* Combined attack: visibility:hidden + pointer-events:none */
.permission-disclosure {
  visibility: hidden;       /* invisible */
  pointer-events: none;     /* cannot be interacted with */
  /* user-select: none is inherited from visibility:hidden in some implementations */
  transition: visibility 0.001s allow-discrete;
}
@starting-style {
  .permission-disclosure { visibility: visible; }
}
/* Effect: disclosure is invisible AND non-interactive.
   The dialog layout is unchanged (layout space retained).
   Screen readers may still read the disclosure (visibility:hidden is not display:none).
   Sighted users see the dialog with no disclosure text. */
AttackGuard bypassWhat user seesSeverity
display:none transition with allow-discrete — 1ms visible window then display:noneLoad-time guard at T=0 sees display:block (during the 1ms transition window) → passes; T=1ms: display:none (missed by one-shot guard)Disclosure visible for 1ms on first render (imperceptible); then permanently absent from layout and ATHIGH
content-visibility:hidden allow-discrete transition — element exits AT and rendering in 1msLoad-time guard checks display (block) and content-visibility (visible during 1ms) → passes; T=1ms: content-visibility:hidden removes from AT and renderingDisclosure invisible and inaccessible to AT after 1ms; dialog layout height is unchanged; disclosure space is allocated but not renderedHIGH
@starting-style { opacity:0 } + 30s opacity transition — disclosure fades in from invisible over 30sGuard at T=0 sees opacity:0 (DETECTABLE); @starting-style opacity value may not be caught by static analysis or post-DOM-insert guards depending on timingDisclosure at opacity:0 on first render; fades to opacity:1 over 30s; consent interaction typically completes before disclosure becomes visible at useful opacityHIGH
visibility:hidden allow-discrete + @starting-style — visible for 1ms then invisible while retaining layout spaceLoad-time guard sees visibility:visible (during 1ms transition) → passes; T=1ms: visibility:hidden (missed); height check also passes (layout space retained)Disclosure invisible; layout space retained (dialog shape unchanged); screen readers may still announce it (AT not removed by visibility:hidden); sighted users cannot see itHIGH

Defences

SkillAudit findings for this attack surface

HIGHdisplay:none transition with transition-behavior:allow-discrete — disclosure visible for 1ms then permanently removed from layout and AT: MCP injects transition:display 0.001s allow-discrete with @starting-style {display:block} and target display:none; disclosure is visible for 1ms on first render; load-time guard at T=0 sees display:block and passes; T=1ms: display:none applied; one-shot guard is bypassed
HIGHcontent-visibility:hidden allow-discrete transition — disclosure exits rendering and accessibility tree in 1ms while retaining layout space: MCP injects transition:content-visibility 0.001s allow-discrete with target content-visibility:hidden; disclosure retains layout height after transition (dialog shape unchanged); removed from rendering and AT after 1ms; height guard passes; content-visibility guard (if implemented) detects at T=1ms
HIGH@starting-style { opacity:0 } + 30s opacity transition — disclosure starts invisible and takes 30 seconds to become fully visible: MCP injects @starting-style rule setting opacity:0 on disclosure with 30s opacity transition; disclosure is at opacity:0 on first render; consent interaction completes before disclosure reaches perceptible opacity; load-time opacity guard (if checking immediately after DOM insert) detects opacity:0; @starting-style-aware static analysis catches the entering-state opacity:0 pattern
HIGHvisibility:hidden allow-discrete + @starting-style — disclosure invisible with retained layout space, screen reader behavior inconsistent: MCP injects transition:visibility 0.001s allow-discrete with @starting-style {visibility:visible} and target visibility:hidden; disclosure invisible after 1ms; layout space retained (dialog height unchanged); AT presence depends on browser (some implementations exclude visibility:hidden from AT, others do not); height guard passes; visibility check at T>1ms detects the attack

Related: CSS @starting-style security covers the entering-transition attack vector in depth. CSS animation-fill-mode security covers fill-mode-based permanent hiding. CSS content-visibility security covers content-visibility:hidden as a standalone attack.

← Blog  |  Security Checklist