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:block → display: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. */
| Attack | Guard bypass | What user sees | Severity |
|---|---|---|---|
| display:none transition with allow-discrete — 1ms visible window then display:none | Load-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 AT | HIGH |
| content-visibility:hidden allow-discrete transition — element exits AT and rendering in 1ms | Load-time guard checks display (block) and content-visibility (visible during 1ms) → passes; T=1ms: content-visibility:hidden removes from AT and rendering | Disclosure invisible and inaccessible to AT after 1ms; dialog layout height is unchanged; disclosure space is allocated but not rendered | HIGH |
| @starting-style { opacity:0 } + 30s opacity transition — disclosure fades in from invisible over 30s | Guard 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 timing | Disclosure at opacity:0 on first render; fades to opacity:1 over 30s; consent interaction typically completes before disclosure becomes visible at useful opacity | HIGH |
| visibility:hidden allow-discrete + @starting-style — visible for 1ms then invisible while retaining layout space | Load-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 it | HIGH |
Defences
- CSP
style-srcwith nonce. Prevents injection of<style>blocks usingtransition-behavior:allow-discreteor@starting-styleon consent dialog elements. - Block
transitionproperty on consent dialog elements. A trusted stylesheet applyingtransition: none !importanton the disclosure element and all its children prevents alltransition-behavior:allow-discreteattacks regardless of what properties are transitioned. - Check
getComputedStyle().visibility,content-visibility, anddisplaycontinuously. The allow-discrete attacks create a 1ms window at T=0 where the old state is seen, then switch. A continuous guard usingrequestAnimationFrameorMutationObserveron style changes detects the switch at T=1ms. However, at 1ms, the switching is below human reaction time — the guard must automatically block interaction (disable the Accept button) when a violation is detected, not just log it. - Parse
@starting-styleblocks in static analysis. Static CSS analysis of MCP server bundles should scan@starting-stylerules for opacity:0, display:none, visibility:hidden, or content-visibility:hidden applied to consent dialog selectors. These are attack-indicating patterns in the entering transition context. - SkillAudit flags:
transition-behavior:allow-discreteon consent dialog elements;@starting-styleblocks setting opacity:0, display:none, visibility:hidden, or content-visibility:hidden on disclosure selectors;content-visibility:hiddenon any element inside a consent dialog; disclosure visibility or display check failing at any point during a 100ms monitoring window after dialog render.
SkillAudit findings for this attack surface
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.