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

High transition-duration:9999s on reveal transition — button stays at near-zero opacity for the entire session; computed opacity is non-zero but below any visible threshold; static audit that checks for transition presence passes; only a plausibility check on duration magnitude detects attack.
High transition-duration:0s on hide class — opacity:0 applied instantaneously at mousedown before click fires; reveal duration (0.3s) appears correct; hide class duration is the attack vector; CSS rule inspection of all matching selectors required.
High Duration underrun — |negative transition-delay| > transition-duration — causes transition to complete at t=0; element immediately holds final hidden state; both values appear individually plausible; cross-property computation required to detect.
High JS mousedown injection of transition-duration:0s — hides button instantaneously via inline style during mousedown window; static CSS analysis cannot detect; MutationObserver during mousedown is the required detection layer.

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.