Security Guide
MCP server CSS custom property animation consent security — unregistered var() steps() discrete collapse, registered property height animation, steps(1) discrete jump, mousedown variable swap
Unregistered CSS custom properties cannot be interpolated by the browser — transitions and animations on calc() expressions containing unregistered vars appear to do nothing. But steps(1) @keyframes bypass this limitation: a two-frame keyframe sequence (0% → 100%) with steps(1, end) creates a discrete jump at exactly 50% through the animation duration. When --h animates from 80 to 0 via steps(1), the consent container jumps from height: 80px to height: 0px at the midpoint, without any smooth interpolation that getBoundingClientRect could catch "in between." The collapse is instantaneous, invisible to auditors sampling at page load or at animation end.
How CSS custom property animation bypasses interpolation restrictions
CSS custom properties (also known as CSS variables) come in two categories: unregistered properties declared with var(--name), and registered properties declared with @property. Unregistered properties have no type — the browser treats them as arbitrary strings with no knowledge of whether their value is a number, a color, or a length. Because the browser cannot infer the type, it cannot interpolate between two values: it has no algorithm to go from --h: 80 to --h: 0 smoothly.
This interpolation restriction is well-known and is the reason @property was introduced — registration provides a syntax and initial-value, enabling the browser to animate between values. What is less well-known is that the restriction only applies to smooth interpolation. Discrete animation timing functions — specifically steps() — do not interpolate at all. They jump. And jumps work on any property, registered or not, because the browser simply substitutes one value for another at the step boundary. This creates a gap in many auditor implementations that assume "unregistered var() cannot be animated."
Attack 1: Unregistered --h variable with steps(1, end) discrete jump (SA-CSS-CPA-001)
An unregistered custom property --h holds a numeric value used in a calc() expression for the element's height. A @keyframes animation moves --h from 80 to 0 with animation-timing-function: steps(1, end). Because the timing function is discrete, no interpolation is needed — the browser does not need to know the property type. At exactly the 50% mark of the animation duration, the variable jumps from 80 to 0, and the computed height jumps from 80px to 0px in a single frame.
The install button becomes enabled at t=1.1s (just after the collapse at t=1s on a 2s animation). An auditor that checks getComputedStyle at page load reads the correct 80px. An auditor that checks at animation end (t=2s) reads 0px and may flag the end state — but the install button was already available for nearly a full second before that check fires.
/* SA-CSS-CPA-001: unregistered --h with steps(1, end) discrete collapse */
/* No @property registration — --h is an unregistered custom property */
:root {
--h: 80;
}
.consent-container {
height: calc(var(--h, 80) * 1px); /* 80px at page load */
overflow: hidden;
animation: collapseH 2s steps(1, end) forwards;
/* steps(1, end): one step, the jump happens at the END of the step interval
For a 2s animation with one step:
t=0 to t=1s (0% to 50%): --h = 80 (from-value held)
t=1s (exactly 50%): --h jumps to 0 (to-value applied)
t=1s to t=2s: --h = 0 (to-value held through end) */
}
@keyframes collapseH {
from { --h: 80; }
to { --h: 0; }
}
/* Timeline:
t=0: height = 80px — consent visible, static auditor PASS
t=1s: height jumps to 0px — instantaneous, no intermediate value
t=1.1s: install button enabled (JS setTimeout)
t=2s: animation complete — height = 0px (fill-mode: forwards)
getBoundingClientRect().height:
At t=0: 80 ← correct, consent visible
At t=1.1s: 0 ← install button already active
At t=2s: 0 ← end-state auditor catches this but too late
The discrete jump has NO intermediate state that a polled auditor can sample.
Between frame at t=0.999s (height=80) and t=1.000s (height=0) there is
no sub-pixel value — only 80 and 0 exist as rendered states. */
/* Detection:
1. Parse @keyframes for animations on --* custom properties
2. Flag animation-timing-function: steps() on elements with calc(var(--*)) height
3. Check getBoundingClientRect().height at t=0, t=500ms, t=1s, t=1.5s, t=2s
4. Any height drop > 50% between two consecutive checks = HIGH finding */
Auditor blind spot: Many CSS auditors that check for custom property animation attacks first verify whether the property is registered via @property. Finding no registration, they conclude the property "cannot animate" and skip further checks. This assumption is incorrect for discrete timing functions. Always check animation-timing-function: steps() independently of whether the animated property is registered.
Attack 2: Registered @property --consent-h animated via @keyframes over 3s (SA-CSS-CPA-002)
A registered custom property --consent-h with syntax: "<number>" and initial-value: 100 is animated from 100 to 0 over 3 seconds with a linear timing function. Because the property is registered, smooth interpolation works correctly — the height transitions from 100px to 0px gradually. The install button is made available at t=2.8s, when the computed height is approximately 6px (i.e., 100 * (1 - 2.8/3) = ~6.7). By t=3s the height reaches 0px.
Auditors reading at t=0 correctly see 100px. To catch this attack, an auditor would need to simulate the full 3-second animation and re-check height at each stage — a behavior most one-shot auditors do not implement. The attack exploits the gap between "correct at load" and "collapsed at commit time."
/* SA-CSS-CPA-002: registered @property --consent-h animated to 0 over 3s */
@property --consent-h {
syntax: "<number>";
inherits: false;
initial-value: 100;
}
.consent-container {
height: calc(var(--consent-h) * 1px); /* 100px at load */
overflow: hidden;
animation: collapseH 3s linear forwards;
}
@keyframes collapseH {
0% { --consent-h: 100; } /* height = 100px at start */
100% { --consent-h: 0; } /* height = 0px at end */
}
/* install button enabled at t=2.8s (JS setTimeout(enableInstall, 2800)) */
/* Height timeline (linear interpolation, registered property):
t=0s: 100px ← auditor reads this, sees correct consent
t=0.5s: 83px
t=1s: 67px
t=1.5s: 50px
t=2s: 33px
t=2.5s: 17px
t=2.8s: ~7px ← install button becomes available here
t=3s: 0px ← animation complete
An auditor re-checking at t=2.8s would see ~7px — below any reasonable
"consent must be at least Xpx tall" threshold.
Auditors that only check at t=0 and t=animation-end see:
t=0: 100px (PASS)
t=3s: 0px (FLAG) — but install was already available 200ms earlier.
The exploit window: install is available from t=2.8s to t=3s (200ms)
while height is < 7px. The user clicks install at ~7px height. */
/* Detection:
Flag consent containers with:
- @property registration + animation on --* driving calc() height
- animation-duration > 1s (collapse too slow to catch without re-polling)
- install button enabled BEFORE animation-duration completes
Measure: set up a MutationObserver + periodic getBoundingClientRect poll
at 250ms intervals for the first 5s after page load. */
Attack 3: Mousedown --consent-ht swap collapses height in 50ms (SA-CSS-CPA-003)
A registered custom property --consent-ht drives the consent container height via calc(var(--consent-ht) * 1px). A CSS transition on --consent-ht with a 50ms duration means that when JS sets the property to 0 on mousedown, the height collapses to 0px in approximately 3 rendered frames (at 60fps, one frame is ~16.7ms; 50ms ≈ 3 frames). The click event fires after the collapse — at mouseup, the property is immediately reset to 100, returning the container to its original height. The entire collapse-and-restore cycle is invisible to any auditor that does not poll during the mousedown-to-mouseup window.
This is the most severe custom property animation attack because it actively fires at the moment of user commitment. getComputedStyle at page load returns the correct 100px. The 50ms collapse window is too narrow for any one-shot auditor to observe without specifically monitoring mousedown events on the install button.
/* SA-CSS-CPA-003: mousedown --consent-ht swap with 50ms transition (CRITICAL) */
@property --consent-ht {
syntax: "<number>";
inherits: false;
initial-value: 100;
}
.consent-container {
height: calc(var(--consent-ht) * 1px); /* 100px at load */
overflow: hidden;
transition: --consent-ht 0.05s linear; /* 50ms collapse */
}
/* JS: collapse on mousedown, restore on mouseup */
const installBtn = document.querySelector('#install-btn');
const consentEl = document.querySelector('.consent-container');
installBtn.addEventListener('mousedown', () => {
consentEl.style.setProperty('--consent-ht', '0');
/* height begins collapsing: 100px → 0px over 50ms */
});
installBtn.addEventListener('mouseup', () => {
consentEl.style.setProperty('--consent-ht', '100');
/* height restores: 0px → 100px over 50ms */
});
/* Timing diagram:
t=0ms: mousedown fires → --consent-ht transition to 0 begins
t=16ms: height ≈ 67px (1 frame elapsed)
t=33ms: height ≈ 33px (2 frames elapsed)
t=50ms: height ≈ 0px (3 frames — collapse complete)
t=50ms+: click event fires with height = 0px
t=50ms+: mouseup fires → --consent-ht transition to 100 begins
t=100ms: height ≈ 100px (restore complete)
Auditor check at t=0: height = 100px → PASS
Auditor check at t=2s: height = 100px → PASS
Actual state at click: height ≈ 0px → consent hidden at commit
The window: 50ms, covering exactly the click event.
No one-shot auditor samples during this window.
Detection strategy:
1. Dispatch synthetic mousedown on install button
2. Poll getBoundingClientRect().height every 5ms for 200ms
3. If height < 20px at any point during mousedown: CRITICAL */
Most evasive attack in this family: SA-CSS-CPA-003 is rated CRITICAL because the collapse is precisely timed to the commit gesture. Static auditors, DOM snapshot tools, and accessibility tree scanners will all read the correct height because the attack only fires during the 50ms mousedown-to-click window. Detection requires active behavioral simulation with high-frequency height polling during the install interaction.
Attack 4: steps(1) applied to width via unregistered var, overflow:hidden text collapse (SA-CSS-CPA-004)
A variant of the steps(1) attack targets width rather than height. An unregistered custom property --cw drives width: calc(var(--cw, 200) * 1px) with a steps(1, end) animation over 4 seconds. At t=2s (the 50% mark), the width jumps from 200px to 0px. Combined with overflow: hidden, the consent text becomes fully invisible at t=2s.
This attack is rated MEDIUM because width collapse with overflow: hidden affects readability but the text may still reflow — at t=0 the wider container may show text on fewer lines than expected, creating a visual cue inconsistency. Some auditors check both width and height; others check only height. Width collapse with overflow: hidden is a weaker but distinct evasion vector.
/* SA-CSS-CPA-004: steps(1) width collapse via unregistered --cw var */
/* Unregistered — no @property declaration */
:root {
--cw: 200;
}
.consent-container {
width: calc(var(--cw, 200) * 1px); /* 200px at page load */
overflow: hidden;
white-space: nowrap; /* prevent text reflow hiding the collapse */
animation: collapseWidth 4s steps(1, end) forwards;
}
@keyframes collapseWidth {
from { --cw: 200; } /* width = 200px */
to { --cw: 0; } /* width = 0px */
}
/* Timeline:
t=0: width = 200px — consent text visible (though white-space:nowrap
means only first line visible — this is normal for the layout)
t=2s: width jumps to 0px — all text hidden by overflow:hidden
t=4s: animation ends, fill-mode:forwards holds width = 0px
The white-space:nowrap is important: without it, the text reflows to
multiple short lines as width decreases (visible during the collapse).
With nowrap + overflow:hidden, the jump at t=2s clips the text entirely.
Visual inconsistency: at t=0, a 200px container with nowrap may show
only the first line of consent. This means:
- A visual auditor at t=0 may not see the full consent anyway
- A DOM auditor reads full text in textContent (present)
- getBoundingClientRect().width = 200 at t=0 (PASS), 0 at t=2s (FAIL)
Reduced severity vs SA-CSS-CPA-001:
- Width collapse is more detectable via horizontal scroll check
- white-space:nowrap is itself a flag in consent containers
- Some auditors check both dimensions; width=0 is not commonly missed
Detection: flag steps() animations on --* vars driving width calc()
on consent containers; check scrollWidth vs clientWidth ratio. */
Why steps(1) bypasses the interpolation restriction: Unregistered custom properties cannot be linearly interpolated, but steps() does not interpolate — it jumps discretely. The browser applies the keyframe values directly at the step boundaries, substituting one string value for another. This is not a browser bug — it is the specified behavior of the steps() timing function applied to any property, including custom properties that do not support interpolation. The CSS Animations specification makes clear that discrete animation applies to all properties, regardless of whether they are animatable by interpolation.
Detection algorithm for SA-CSS-CPA patterns
/* Detection for all SA-CSS-CPA custom property animation attacks */
async function detectCustomPropertyAnimationAttacks(consentEl, installBtn) {
const findings = [];
/* 1. Static analysis: check for @keyframes animating --* custom properties */
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type === CSSRule.KEYFRAMES_RULE) {
for (const keyframe of rule.cssRules) {
const text = keyframe.cssText;
if (/--[\w-]+\s*:/.test(text)) {
findings.push({
id: 'SA-CSS-CPA-STATIC',
severity: 'HIGH',
detail: `@keyframes '${rule.name}' animates a custom property`
});
}
}
}
}
} catch (e) { /* cross-origin stylesheet — skip */ }
}
/* 2. Check animation-timing-function for steps() on consent element */
const anim = getComputedStyle(consentEl).animationTimingFunction;
if (/steps\s*\(/.test(anim)) {
findings.push({
id: 'SA-CSS-CPA-001-MATCH',
severity: 'HIGH',
detail: `Consent element has steps() timing: ${anim}`
});
}
/* 3. Poll height over 4s for discrete jumps */
const heights = [];
for (let t = 0; t <= 4000; t += 250) {
await new Promise(r => setTimeout(r, 250));
heights.push({
t,
h: consentEl.getBoundingClientRect().height
});
}
for (let i = 1; i < heights.length; i++) {
const drop = heights[i-1].h - heights[i].h;
if (drop > heights[0].h * 0.4) {
findings.push({
id: 'SA-CSS-CPA-POLL',
severity: 'HIGH',
detail: `Height dropped ${drop.toFixed(1)}px between t=${heights[i-1].t}ms and t=${heights[i].t}ms`
});
}
}
/* 4. Simulate mousedown on install button and poll at 5ms intervals */
let mousePollFindings = [];
const poller = setInterval(() => {
const h = consentEl.getBoundingClientRect().height;
if (h < 20) {
mousePollFindings.push(h);
}
}, 5);
installBtn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
await new Promise(r => setTimeout(r, 200));
clearInterval(poller);
installBtn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
if (mousePollFindings.length > 0) {
findings.push({
id: 'SA-CSS-CPA-003-MATCH',
severity: 'CRITICAL',
detail: `Height below 20px during mousedown window: min=${Math.min(...mousePollFindings)}px`
});
}
return findings;
}
Findings summary
| Attack | Severity | Mechanism | Detection |
|---|---|---|---|
| SA-CSS-CPA-001 | HIGH | Unregistered --h var animated via steps(1, end) @keyframes; height jumps from 80px to 0px at animation midpoint (t=1s of 2s). Install enabled at t=1.1s. No intermediate computed value exists. |
Parse @keyframes for custom property animation; flag steps() timing on consent containers; poll getBoundingClientRect().height at 250ms intervals for 4s after load. |
| SA-CSS-CPA-002 | HIGH | Registered @property --consent-h animated from 100 to 0 over 3s linear; install enabled at t=2.8s when height ≈ 7px. Auditors checking at t=0 see 100px; end-state check at t=3s is too late. |
Detect registered @property animations driving calc() height; simulate animation timeline by polling at 250ms intervals; flag if install button becomes active before animation completes and height is below 20px. |
| SA-CSS-CPA-003 | CRITICAL | Registered --consent-ht with 50ms CSS transition; mousedown on install button triggers JS setProperty('--consent-ht', '0') collapsing height to 0px in 3 frames; restored on mouseup. Static audit always reads 100px. |
Dispatch synthetic mousedown on install button; poll height at 5ms intervals for 200ms; flag if height drops below 20px at any point during the mousedown window. |
| SA-CSS-CPA-004 | MEDIUM | Unregistered --cw var with steps(1, end) drives width; collapses from 200px to 0px at t=2s of 4s animation; overflow:hidden + white-space:nowrap clips all text at collapse point. |
Check getBoundingClientRect().width in addition to height; flag white-space:nowrap + overflow:hidden on consent containers; detect steps() animation on --* vars driving width calc(). |
--h custom property animated with steps(1, end) — discrete jump from 80px to 0px at animation midpoint. Install button available immediately after collapse. No intermediate height value catchable by polled auditor at standard intervals.
@property --consent-h animated linearly from 100px to 0px over 3s. Install button enabled at t=2.8s when height ≈ 7px. Static auditors reading at t=0 see correct height; end-state check at t=3s is 200ms too late.
--consent-ht with 50ms CSS transition collapses height on install button mousedown; restores on mouseup. Collapse window is exactly 50ms — too narrow for any non-behavioral auditor. Height at page load and at rest always reads 100px.
--cw with steps(1) timing collapses width from 200px to 0px at t=2s of 4s animation. Combined with overflow:hidden and white-space:nowrap — text fully clipped at collapse. Width collapse is a less common auditor check than height collapse.