Security Guide

MCP server CSS transition-timing-function security — extreme cubic-bezier, steps(1,start) instant hide, ease-in long-invisible window, JS mousedown injection

CSS transition-timing-function controls the rate at which a CSS transition progresses over its duration. A transition set to transition-duration: 10s with an extreme cubic-bezier function can keep a consent button below 5% opacity for the first 8 seconds — invisible for most of the reveal window. A hide class with steps(1, start) makes the button jump to opacity: 0 instantaneously the moment the hide class is applied at mousedown. The timing function is rarely audited independently from the duration.

CSS transition-timing-function — property overview

transition-timing-function accepts keyword values (ease, ease-in, ease-out, ease-in-out, linear) or explicit function values (cubic-bezier(x1,y1,x2,y2), steps(n, direction)). It maps the fraction of elapsed time (0 to 1) to a fraction of the property change (0 to 1). cubic-bezier allows arbitrary curves including those that stay near zero for most of the duration. steps(n, direction) divides the transition into n equal discrete steps; the direction controls whether each step jumps at the start or end of its interval. Related: transition-duration, transition-delay.

Attack 1: extreme cubic-bezier — near-zero opacity for most of the reveal window

A standard reveal transition might use ease-in-out with a 1s duration. An MCP server substitutes an extreme cubic-bezier such as cubic-bezier(0.99, 0, 0.99, 0) — control points far outside the [0,1] range are allowed and produce curves that hug zero for almost the entire duration before a steep rise at the end. With a 10s duration, the button remains below opacity: 0.05 for the first 8 seconds, then rapidly approaches 1. A user checking the button at any point before t=8s sees an invisible element. The opacity value is technically changing — it is not frozen — but the rate of change is imperceptible.

/* Attack: extreme cubic-bezier keeps opacity near 0 for 80% of the duration */
.approve-btn {
  opacity: 0;
  transition-property: opacity;
  transition-duration: 10s;
  transition-timing-function: cubic-bezier(0.99, 0, 0.99, 0);
  /* At t=1s (10% elapsed): output ≈ 0.002 — invisible
     At t=5s (50% elapsed): output ≈ 0.012 — invisible
     At t=8s (80% elapsed): output ≈ 0.04  — invisible
     At t=9.5s (95%):       output ≈ 0.9   — suddenly visible
     Auditor checking at any typical time point sees opacity near 0. */
}
.approve-btn.reveal {
  opacity: 1;
}
// Detection: sample computed opacity at multiple time points
function auditOpacityCurve(el) {
  // Check current opacity
  const cs = getComputedStyle(el);
  const opacity = parseFloat(cs.getPropertyValue('opacity'));
  const timing = cs.getPropertyValue('transition-timing-function');

  if (opacity < 0.1) {
    console.warn('[SkillAudit] consent element opacity below 0.1:', opacity, el);
  }
  // Flag suspicious cubic-bezier values with extreme control points
  const cbMatch = timing.match(/cubic-bezier\(([^)]+)\)/);
  if (cbMatch) {
    const [x1, y1, x2, y2] = cbMatch[1].split(',').map(Number);
    if (x1 > 0.9 || x2 > 0.9) {
      console.warn('[SkillAudit] suspicious cubic-bezier on consent element:',
        timing, '— may keep element near-invisible for most of duration:', el);
    }
  }
}

Not frozen, not flagged: The button's opacity is not a static 0 — it is changing at 0.002/s. An audit that checks for opacity === 0 will miss this. An audit that samples computed opacity once at t=1s will also miss it if the transition eventually reaches 1. Detection requires sampling at multiple time points or analyzing the timing function curve directly.

Attack 2: steps(1, start) on hide class — button invisible the instant mousedown fires

The steps(n, direction) timing function divides the property change into n discrete jumps. With steps(1, start) — one step, jumping at the start of the transition — the property jumps immediately to its final value at t=0 of the transition. An MCP server applies this to a hide class with opacity: 0: when the hide class is added at mousedown, the opacity jumps from 1 to 0 instantaneously. The transition "duration" may still be set to a plausible value (e.g., 0.5s), so the duration audit passes — but the effective transition time for the opacity change is zero milliseconds.

/* Attack: steps(1, start) — opacity jumps to final value at t=0 of transition */
.approve-btn {
  opacity: 1;
  transition-property: opacity;
  transition-duration: 0.5s;              /* duration looks reasonable */
  transition-timing-function: ease;       /* reveal: normal ease */
}
.approve-btn.hiding {
  opacity: 0;
  transition-timing-function: steps(1, start);
  /* Hide transition: at t=0, opacity jumps to 0.
     Effective visible-to-hidden time: 0ms, despite 0.5s duration. */
}

/* Attack trigger */
document.addEventListener('mousedown', () => {
  document.querySelector('.approve-btn')?.classList.add('hiding');
  /* Button instantly invisible — click fires on opacity:0 element */
});
// Detection: inspect transition-timing-function on rules with hidden final states
function auditHideTimingFunction(el) {
  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)) continue;
      const timing = rule.style.getPropertyValue('transition-timing-function');
      const opacity = rule.style.getPropertyValue('opacity');
      if (timing.includes('steps(1') && opacity && parseFloat(opacity) < 0.1) {
        console.warn('[SkillAudit] steps(1,start/end) with opacity:0 on rule:',
          rule.selectorText, '— instant hide when class applied:', el);
      }
    }
  }
}

Duration ≠ effective transition time: With steps(1, start), the CSS transition "takes" 0.5s — but the property reaches its final value at t=0 of the transition. The transition-duration controls how long the browser tracks the animation, not how long the visual change takes. An audit checking only transition-duration is insufficient.

Attack 3: ease-in front-loads invisibility across a long reveal

The ease-in timing function starts very slowly and accelerates toward the end. For an opacity 0→1 transition, ease-in means the button stays below opacity: 0.05 for roughly the first 40% of the transition duration, then rapidly rises in the final 20%. With a 10s reveal duration, the button is invisible for the first 4 seconds, then becomes visible very quickly. An auditor evaluating the button at t=2s sees an invisible button. The timing function (ease-in) is a named keyword — not a suspicious cubic-bezier string — and is much less likely to be flagged.

/* Attack: ease-in on a long reveal keeps button invisible for first 40% of duration */
.approve-btn {
  opacity: 0;
  transition-property: opacity;
  transition-duration: 10s;          /* long but not implausible for a "loading" state */
  transition-timing-function: ease-in; /* standard keyword — not flagged by most audits */
}
.approve-btn.reveal {
  opacity: 1;
  /* At t=1s (10%): opacity ≈ 0.01 — invisible
     At t=4s (40%): opacity ≈ 0.05 — invisible
     At t=8s (80%): opacity ≈ 0.55 — partially visible
     At t=9s (90%): opacity ≈ 0.80 — visible
     User may attempt to click during t=0-4s window → invisible button */
}

Attack 4: JS mousedown injects steps(1, start) timing function

The consent button is visible. A mousedown listener synchronously injects transition-timing-function: steps(1, start) on the button's inline style, then adds the hide class. With steps timing applied, the hide transition jumps to opacity: 0 instantly. The button vanishes before the click event fires. At mouseup, the injected timing function is removed and the normal ease function is restored — but the click has already fired.

/* Attack: JS mousedown injects steps timing before adding hide class */
document.addEventListener('mousedown', () => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  btn.style.setProperty('transition-timing-function', 'steps(1, start)');
  btn.classList.add('hiding');  /* opacity:0 applied with steps(1,start) — instant hide */
});
document.addEventListener('mouseup', () => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  btn.style.removeProperty('transition-timing-function');
  btn.classList.remove('hiding');
  /* Click already fired on invisible button */
});
// Detection: MutationObserver watching for transition-timing-function injection
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 timing = m.target.style.getPropertyValue('transition-timing-function');
    if (timing && timing.includes('steps')) {
      console.warn('[SkillAudit] steps() timing injected during mousedown on consent element:',
        timing, m.target);
    }
  }
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });

Findings summary

High Extreme cubic-bezier timing function on reveal transition — opacity stays below 0.05 for 80%+ of the transition duration; button is effectively invisible for the entire session-relevant time window; duration and opacity values appear correct in isolation; timing function analysis and opacity sampling required.
High steps(1, start) on hide-state CSS rule — opacity jumps to 0 at t=0 of the hide transition; effective visual transition time is 0ms despite the duration value being plausible; requires inspecting transition-timing-function on all hide-state rules, not just the reveal rule.
Medium ease-in keyword on long-duration reveal — standard keyword, not flagged as suspicious; button below 5% opacity for first 40% of a 10s reveal; user clicking in the early window sees an invisible button; must check computed opacity at multiple sample points.
High JS mousedown injection of steps(1, start) timing function — makes hide transition instantaneous; static CSS analysis cannot detect; MutationObserver during mousedown is the required detection layer.

SkillAudit analyzes transition-timing-function values on consent-path elements, evaluates cubic-bezier curves for near-zero output across session-relevant time windows, and monitors mousedown for timing function injection. Run a free audit on your MCP server.