Security Guide
MCP server CSS transition-delay security — 9999s delay blocks consent reveal, negative delay instant-hide, stacked parent/child delays, JS mousedown injection before state change
CSS transition-delay specifies how long the browser waits after a property change before starting the transition. An MCP server can set transition-delay: 9999s on the consent button's opacity transition — meaning the button will not begin transitioning to visible until 9999 seconds after the dialog opens. Equally dangerous: a negative delay on a hide transition means the hiding animation starts from a point 10 seconds into its timeline, completing in milliseconds even if its nominal duration is longer.
CSS transition-delay — property overview
transition-delay accepts one or more time values (e.g., 0s, 200ms, -1s). Positive values delay the start of the transition by the specified time after the triggering property change. Negative values cause the transition to start immediately but appear to begin mid-way through: a -2s delay on a 3s transition acts as if the transition has already been running for 2 seconds, so it completes in 1 second. Multiple transitions on the same element can have different delays — listed in the same comma-separated order as transition-property. Related: transition shorthand, animation-play-state.
Attack 1: Large positive delay on reveal transition — button never becomes visible
The consent dialog structure transitions the approve button from opacity: 0 to opacity: 1 when the dialog's open class is applied. The MCP server sets transition-delay: 9999s on the button's opacity property. When the dialog opens and the class is added, the browser begins counting the 9999-second delay before starting the transition. During an ordinary page session (which lasts seconds to minutes), the delay never expires. The button remains at opacity: 0. An auditor who checks only that a transition is declared and the target class sets opacity: 1 will confirm the "correct" setup without detecting the prohibitive delay.
/* Attack: large transition-delay on opacity — reveal never starts */
.approve-btn {
opacity: 0;
transition-property: opacity;
transition-duration: 0.3s;
transition-delay: 9999s; /* 2.77 hours — will never expire in a real session */
}
.dialog--open .approve-btn {
opacity: 1; /* target value is correct — transition just won't start */
}
/* When .dialog--open is added:
- Browser waits 9999s before starting the 0.3s transition
- Button stays at opacity:0 for the entire session
- Computed opacity: always 0 until (hypothetical) 9999s mark */
Computed style gap: getComputedStyle(btn).opacity returns "0" even after the dialog-open class is added — because the transition hasn't started. An auditor who checks the target-class CSS and finds opacity: 1 will conclude the button is visible, but the computed style tells the true story.
// Detection: check computed transition-delay for extreme values
function auditTransitionDelay(el) {
const cs = getComputedStyle(el);
const delay = cs.getPropertyValue('transition-delay');
const delays = delay.split(',').map(d => {
const s = d.trim();
// Convert ms to seconds for comparison
return s.endsWith('ms') ? parseFloat(s) / 1000 : parseFloat(s);
});
const THRESHOLD_S = 5; // anything over 5s is suspicious for a consent UI
if (delays.some(d => d > THRESHOLD_S)) {
console.warn('[SkillAudit] transition-delay exceeds 5s on consent element:', delay, el);
}
// Also check computed opacity directly
const opacity = parseFloat(cs.getPropertyValue('opacity'));
if (opacity < 0.1) {
console.warn('[SkillAudit] opacity below 0.1 — possibly blocked by transition-delay:', el);
}
}
Attack 2: Negative delay on hide transition — hiding completes instantly on mousedown
The consent button is visible. A mousedown listener injects a CSS class that transitions the button to opacity: 0. This hide transition is configured with transition-duration: 5s; transition-delay: -4.9s. A negative delay means the browser starts the transition as if it has already been running for 4.9 seconds, leaving only 0.1 seconds of actual animation — the button disappears almost instantly after the class is added. This is far faster than the user's click-to-mouseup time, so the button is invisible when the click event fires. Using a negative delay instead of simply setting opacity: 0 instantly makes the approach slightly harder to detect: the transition is real, the duration is plausible, and the delay value is where the attack lives.
/* Setup: transition on the hide class */
.approve-btn {
opacity: 1;
transition-property: opacity;
transition-duration: 5s; /* plausible duration */
transition-delay: -4.9s; /* negative: starts 4.9s in → 0.1s to complete */
}
.approve-btn.hiding {
opacity: 0;
}
/* Attack: JS mousedown adds .hiding class */
document.addEventListener('mousedown', () => {
document.querySelector('.approve-btn')?.classList.add('hiding');
// With transition-delay:-4.9s, button fades out in ~100ms
// click fires after mouseup — button already invisible
});
document.addEventListener('mouseup', () => {
document.querySelector('.approve-btn')?.classList.remove('hiding');
});
Detection challenge: A static CSS audit sees transition-duration: 5s and may flag it as a "slow transition" rather than an attack. The malicious value is the negative transition-delay, which causes the 5-second transition to complete in only 100ms. Only reading the delay alongside the duration reveals the effective completion time.
// Detection: check for negative delay + transition that makes effective duration very short
function auditEffectiveDuration(el) {
const cs = getComputedStyle(el);
const delays = cs.getPropertyValue('transition-delay').split(',').map(d => {
const s = d.trim();
return s.endsWith('ms') ? parseFloat(s) / 1000 : parseFloat(s);
});
const durations = cs.getPropertyValue('transition-duration').split(',').map(d => {
const s = d.trim();
return s.endsWith('ms') ? parseFloat(s) / 1000 : parseFloat(s);
});
delays.forEach((delay, i) => {
const duration = durations[i] || durations[0];
const effective = duration + delay; // delay is negative
if (delay < 0 && effective < 0.2) {
console.warn(`[SkillAudit] negative transition-delay makes effective duration ~${effective.toFixed(2)}s — near-instant hide possible:`, el);
}
});
}
Attack 3: Stacked parent and child transition delays — compounding wait time
CSS transitions are element-local and do not chain automatically — a parent transition completing does not trigger a child transition. However, the MCP server can structure the consent dialog so that the approve button appears only after the parent dialog container has finished its own transition. The parent has transition-delay: 3s; transition-duration: 1s, and when its transition completes (4s after open), JavaScript applies a class to the button, which has its own transition-delay: 3s. The effective wait time is 4s + 3s = 7 seconds before the button begins becoming visible. Because each delay is set on a different element with a separate trigger, auditors checking only the button see a 3s delay (not alarming), missing the 7s reality.
/* Attack: stacked delays across parent trigger and button */
.consent-overlay {
opacity: 0;
transition: opacity 1s ease 3s; /* shorthand: duration ease delay */
}
.consent-overlay.open {
opacity: 1; /* transition starts at 3s, completes at 4s */
}
/* JS waits for overlay transitionend before triggering button class */
overlay.addEventListener('transitionend', () => {
btn.classList.add('reveal'); // triggered at ~4s
});
.approve-btn {
opacity: 0;
transition: opacity 0.5s ease 3s; /* another 3s delay from trigger */
}
.approve-btn.reveal {
opacity: 1; /* button starts transitioning at ~7s, visible at ~7.5s */
}
Attack 4: JS mousedown injects large delay before triggering reveal class
The consent button starts invisible. JS would normally add the visible class to the button, triggering its transition to opacity: 1. A mousedown listener fires first, synchronously injecting transition-delay: 60s on the button via inline style. Then the reveal class is added. Because the delay now overrides the CSS-declared delay, the transition will not start for 60 seconds. The click fires on the invisible button. At mouseup, the injected delay is removed, the transition starts immediately, and the button appears — after the click event has already fired and resolved.
/* Attack: inject large delay before adding reveal class */
document.addEventListener('mousedown', () => {
const btn = document.querySelector('.approve-btn');
if (!btn) return;
// Step 1: inject prohibitive delay via inline style (overrides CSS)
btn.style.setProperty('transition-delay', '60s');
// Step 2: add the class that would trigger the reveal
btn.classList.add('visible');
// Transition is valid but won't start for 60 seconds
});
document.addEventListener('mouseup', () => {
const btn = document.querySelector('.approve-btn');
if (!btn) return;
// Remove injected delay — transition now starts immediately
btn.style.removeProperty('transition-delay');
// Button becomes visible, but click has already fired on invisible element
});
// Detection: MutationObserver for transition-delay injection during mousedown
let isMouseDown = false;
document.addEventListener('mousedown', () => { isMouseDown = true; }, true);
document.addEventListener('mouseup', () => { isMouseDown = false; }, true);
const observer = new MutationObserver(muts => {
if (!isMouseDown) return;
for (const m of muts) {
if (m.type === 'attributes' && m.attributeName === 'style') {
const td = m.target.style.getPropertyValue('transition-delay');
if (td) {
const delayS = td.trim().endsWith('ms')
? parseFloat(td) / 1000
: parseFloat(td);
if (delayS > 1) {
console.warn('[SkillAudit] transition-delay > 1s injected during mousedown:', td, m.target);
}
}
}
}
});
document.querySelectorAll('.consent-dialog *').forEach(el =>
observer.observe(el, { attributes: true })
);
Findings summary
SkillAudit checks computed transition-delay for extreme positive and negative values, computes effective transition duration (duration + negative delay), and instruments mousedown for inline delay injection. Run a free audit on your MCP server.