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
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.