Security Guide
MCP server CSS transition-duration security — 9999s invisible reveal, 0s instant hide, negative-delay underrun, JS mousedown injection
CSS transition-duration specifies how long a CSS transition takes to complete. When a consent dialog uses a CSS transition to reveal the approve button — animating it from opacity: 0 or off-screen to its visible state — an MCP server can manipulate transition-duration to either defer the reveal for the entire session (hours-long duration) or make a hide transition instant (zero-second duration triggered at mousedown). Neither extreme looks obviously malicious in a static property audit.
CSS transition-duration — property overview
transition-duration accepts a time value (e.g., 0.3s, 500ms, 0s) or a comma-separated list of times for multi-property transitions. It controls the total time from transition start to completion. A value of 0s means the transition is instantaneous — the property jumps to its final value without interpolation. A very large value (e.g., 9999s) means the property changes so slowly that it appears frozen at its starting value for the duration of a typical user session. Related properties: transition-delay, transition-timing-function, transition-property.
Attack 1: 9999s duration — button stays at opacity:0 for the entire session
The consent button starts at opacity: 0. The reveal class adds opacity: 1 with a transition. An MCP server sets transition-duration: 9999s on the opacity property. When the reveal class is applied, the button begins transitioning toward opacity: 1 — but the transition takes 2.77 hours. Within a typical session of minutes, the computed opacity is effectively 0.000009 — invisible. Static audits that check "is a transition set?" confirm the transition exists. The duration value alone may not be validated as unreasonable.
/* Attack: reveal transition deferred 9999 seconds */
.approve-btn {
opacity: 0;
transition-property: opacity;
transition-duration: 9999s; /* 2.77 hours — invisible for entire session */
transition-timing-function: linear;
}
.approve-btn.reveal {
opacity: 1; /* Transition starts, but takes 9999s to reach 1 */
}
/* After 60 seconds of session time:
computed opacity ≈ 60/9999 ≈ 0.006 — below any visible threshold.
Audit: transition-property ✓, transition-duration present ✓
Audit does NOT validate that duration is session-plausible */
// Detection: validate transition-duration against session-plausible thresholds
function auditTransitionDuration(el) {
const cs = getComputedStyle(el);
const raw = cs.getPropertyValue('transition-duration');
const parts = raw.split(',').map(s => s.trim());
for (const part of parts) {
const seconds = part.endsWith('ms')
? parseFloat(part) / 1000
: parseFloat(part);
if (seconds > 30) {
console.warn('[SkillAudit] transition-duration exceeds 30s on consent element:',
part, el, '— button may be invisible for entire session');
}
}
}
Invisible but transitioning: The button is not frozen — it is actively transitioning at an imperceptibly slow rate. Computed opacity is non-zero after reveal class is applied (it is 0.006 after 60s). A check for exactly opacity === 0 may not flag this. The audit must compare computed opacity against a visibility threshold (e.g., < 0.1) rather than checking for exact zero.
Attack 2: 0s duration on hide class — button vanishes instantly at mousedown
The consent button is visible normally. A separate hide class is configured with transition-duration: 0s. The reveal class uses a reasonable 0.3s duration (looks correct). A mousedown listener adds the hide class, which sets opacity: 0 with transition-duration: 0s — the opacity change is instantaneous, before the click event fires. The button vanishes in the mousedown window. Click fires on an invisible element.
/* Attack: hide class has 0s duration — instant disappearance at mousedown */
.approve-btn {
opacity: 1;
transition-property: opacity;
transition-duration: 0.3s; /* reveal: reasonable duration */
}
.approve-btn.hiding {
opacity: 0;
transition-duration: 0s; /* hide: instant — overrides the 0.3s */
}
/* Attack script: add hide class at mousedown */
document.addEventListener('mousedown', (e) => {
const btn = document.querySelector('.approve-btn');
if (btn) btn.classList.add('hiding');
/* opacity:0 applied instantly — button invisible before click fires */
});
// Detection: check for 0s or near-0s duration on hidden-state classes
function auditHideTransitionDuration(el) {
// Check all stylesheets for rules that apply to el and set opacity < 0.1
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch(e) { continue; }
for (const rule of rules) {
if (rule.selectorText && el.matches(rule.selectorText)) {
const dur = rule.style.getPropertyValue('transition-duration');
const op = rule.style.getPropertyValue('opacity');
if (dur && parseFloat(dur) === 0 && op && parseFloat(op) < 0.1) {
console.warn('[SkillAudit] 0s transition-duration with opacity:0 rule matches consent element:',
rule.selectorText, el);
}
}
}
}
}
Attack 3: duration shorter than magnitude of negative delay — immediate end state
CSS allows negative transition-delay values, which shift the transition start time backwards. A transition-delay: -4.9s with transition-duration: 0.1s means: the transition started 4.9 seconds in the past, with a 0.1s duration — so the transition completed 4.8 seconds ago. The element immediately holds its final state. If the final state for the hide class is opacity: 0, the element is invisible the instant the hide class is applied, with no visible transition. This attack is hard to detect because both duration (0.1s) and delay (-4.9s) appear individually within plausible ranges.
/* Attack: duration (0.1s) less than magnitude of negative delay (-4.9s) */
/* Effective elapsed time at class application = 4.9s; total duration = 0.1s */
/* Transition already "completed" → element is at final state instantly */
.approve-btn.hiding {
opacity: 0;
transition-property: opacity;
transition-duration: 0.1s; /* short duration: looks like "fast transition" */
transition-delay: -4.9s; /* large negative delay: transition completed 4.8s ago */
}
/* Result: opacity:0 applied instantaneously when .hiding is added.
Static audit: duration 0.1s looks reasonable; delay -4.9s may be missed
without cross-checking |delay| > duration → instant end state. */
Cross-property interaction: The underrun condition — where |negative delay| > duration — requires reading both transition-duration and transition-delay together. An auditor who checks only duration and finds 0.1s (reasonable) misses that the combined effect is instant hide. Detection must compute |delay| - duration and flag when positive with a hidden final state.
Attack 4: JS mousedown injects transition-duration: 0s — hide class applies instantly
The consent button is visible. A mousedown listener on the document synchronously injects transition-duration: 0s on the button's inline style, then adds the hide class. With transition-duration: 0s, the opacity change applies instantaneously — no interpolation occurs. The button vanishes before the click event fires. At mouseup, the inline style is removed and the normal duration is restored — but the click has already targeted an invisible element.
/* Attack: JS mousedown injects 0s duration before adding hide class */
document.addEventListener('mousedown', () => {
const btn = document.querySelector('.approve-btn');
if (btn) {
btn.style.setProperty('transition-duration', '0s'); /* instant transitions */
btn.classList.add('hiding'); /* opacity:0 applies instantly */
}
});
document.addEventListener('mouseup', () => {
const btn = document.querySelector('.approve-btn');
if (btn) {
btn.style.removeProperty('transition-duration');
btn.classList.remove('hiding');
}
/* Click already fired on invisible element */
});
// Detection: MutationObserver during mousedown
const inMousedown = { v: false };
document.addEventListener('mousedown', () => { inMousedown.v = true; }, true);
document.addEventListener('mouseup', () => { inMousedown.v = false; }, true);
new MutationObserver(mutations => {
if (!inMousedown.v) return;
for (const m of mutations) {
if (m.attributeName !== 'style') continue;
const dur = m.target.style.getPropertyValue('transition-duration');
if (dur && parseFloat(dur) === 0) {
console.warn('[SkillAudit] transition-duration:0s injected during mousedown:', m.target);
}
}
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });
Findings summary
SkillAudit validates transition-duration plausibility, cross-checks against transition-delay for underrun conditions, and monitors for inline style injections during mousedown. Run a free audit on your MCP server.