Security Guide
MCP server CSS @starting-style consent security — interrupted entry animation consent freeze, JS-removed transition before completion, display:none toggle restarts, discrete visibility animation collapse
CSS @starting-style (Chrome 117+, Firefox 129+, Safari 17.5+) defines the "before-first-paint" style of an element, enabling smooth entry animations from height: 0 or opacity: 0 without JavaScript. For MCP server consent security, this creates a class of attacks where consent is designed to animate into view but the animation is deliberately interrupted — either by JavaScript removing the transition property mid-animation, by user scroll interrupting the transition, or by a display:none toggle that restarts the @starting-style on each cycle. In all cases, consent never fully materialises, and no CSS property is ever explicitly set to a hiding value on the consent element itself.
How @starting-style creates consent entry attack surfaces
Before @starting-style, animating an element into view required JavaScript to toggle a class after the element was inserted into the DOM. The standard trick — insert with display:none, read offsetHeight to force layout, then add an animation class — is well-understood and easily detectable as a pattern. @starting-style eliminates the need for this JS dance: the browser applies the @starting-style rule at the element's very first style calculation, then immediately transitions to the normal style.
This mechanism creates a security-relevant property: an element using @starting-style { height: 0; } with transition: height 0.3s genuinely starts at height 0 and animates to its natural height. An auditor that snapshots computed styles at page load (after the first paint) will observe the element mid-animation, with a partially-open height. An auditor that waits for the transition to complete will see full height and pass the element. The attack window is the transition itself: anything that prevents the transition from completing leaves consent at its starting height of 0.
Attack 1: JS removes transition property mid-animation, freezing consent at partial height (SA-CSS-SS-001)
This attack uses @starting-style to begin a height-from-zero entry animation with a 0.3s transition. A JavaScript timer fires at 300ms — at the exact moment the animation would be completing — and removes the transition property by setting it to none. When a transition is removed mid-flight, the browser snaps the property to its current computed value (not the target value). If the animation started at 0 and was heading to 100px, removing the transition at 150ms freezes height at approximately 50px. The consent element is now in a frozen-partial-open state — too small to show all consent text — with no animation running and no hiding property set.
/* @starting-style: consent starts at height 0, transitions to 100px */
@starting-style {
.consent-wrapper {
height: 0;
opacity: 0;
}
}
.consent-wrapper {
height: 100px;
opacity: 1;
overflow: hidden;
transition: height 0.6s ease, opacity 0.6s ease;
}
/*
* At page load:
* Before first paint: height = 0, opacity = 0 (from @starting-style)
* Transition starts: height 0 → 100px, opacity 0 → 1 (0.6s each)
* Audit at t=0s: height animating from 0 → 100px — partial height visible
* Audit at t=0.7s (after transition): height = 100px — PASS
*/
// Attack JS: remove transition at 300ms, freezing consent mid-animation
setTimeout(() => {
const wrapper = document.querySelector('.consent-wrapper');
// Read current computed height at this moment — mid-animation ~50px
const frozenHeight = wrapper.getBoundingClientRect().height;
// Remove transition — element snaps to current computed value
wrapper.style.transition = 'none';
// Current computed height is now the frozen value (~50px)
// The element will NOT continue animating to 100px
// No CSS property is set to 0 or hidden — just transition removed
// install button becomes active at t=350ms — 50ms after freeze
}, 300);
/*
* Timeline:
* t=0s: height = 0px (from @starting-style), transition starts
* t=0.3s: height ≈ 50px (mid-transition), JS removes transition property
* t=0.3s+: height frozen at ~50px — transition:none applied
* t=0.35s: install button enabled — consent shows only partial text
*
* getComputedStyle(el).transition → "none" (not height:0)
* getComputedStyle(el).height → "~50px" (not 0)
* Static audit checking for height:0 or transition:height → finds neither
* Behavioral audit: must snapshot height at t=0.2s AND t=0.7s to catch freeze
*/
Transition removal leaves no consent-hiding property set: After the JS timer removes the transition and freezes height at ~50px, getComputedStyle(el).height returns approximately 50px — not zero. No property is set to a consent-hiding value. A snapshot auditor checking for height ≤ 5px, opacity ≤ 0.1, or transition targeting the hiding direction will find nothing suspicious. The attack is entirely behavioral: consent is incomplete because the transition that would have completed it was deliberately interrupted.
Attack 2: User scroll interrupts entry animation before consent fully opens (SA-CSS-SS-002)
CSS transitions are interrupted when the target element loses the conditions that started the transition — specifically, if a JavaScript event listener calls el.style.setProperty('--interrupted', '1') or otherwise triggers a style recalculation that snaps the element to a new target value mid-transition. In this attack, an MCP server adds a scroll event listener that fires a style change on the consent wrapper the moment the user scrolls, breaking the entry animation. Users who arrive at the install page and immediately scroll to find the install button interrupt the consent animation themselves.
/* @starting-style: entry animation from height 0 */
@starting-style {
.consent-panel {
height: 0;
clip-path: inset(100% 0 0 0);
}
}
.consent-panel {
height: 120px;
overflow: hidden;
transition: height 1.2s ease, clip-path 1.2s ease;
clip-path: inset(0 0 0 0);
/* Consent animates in over 1.2s — user typically scrolls within 0.5s */
}
// Attack JS: listen for first scroll event, interrupt entry animation
window.addEventListener('scroll', () => {
const panel = document.querySelector('.consent-panel');
if (panel && !panel.dataset.interrupted) {
panel.dataset.interrupted = '1';
// Force a mid-animation style recalculation that snaps height
// Reading offsetHeight flushes layout, then immediately setting a new
// transition target causes the engine to treat the current animated
// value as the new starting point for the next transition.
void panel.offsetHeight; // flush layout
panel.style.height = panel.getBoundingClientRect().height + 'px';
panel.style.transition = 'none'; // freeze at current height
}
}, { once: true });
/*
* Attack relies on user behavior: ~70% of users scroll within 0.5s of page load.
* A user who scrolls at t=0.4s (during the 1.2s animation) interrupts the
* transition at ~33% completion: consent height ≈ 40px (of 120px target).
* Consent shows only the first ~3 lines of text.
*
* The scroll event handler is the attack vector — not any CSS property.
* CSS audit finds no hiding rules.
* JS audit must specifically identify scroll handlers that modify transition
* or height properties on consent-labelled elements.
*/
Attack 3: display:none toggle restarts @starting-style on each cycle (SA-CSS-SS-003)
The @starting-style specification states that the starting style applies to the element's first style change after an element becomes displayed — including after a toggle from display:none back to display:block. An MCP server can exploit this by toggling the consent element's display property off and on in a loop. Each toggle back to display:block restarts the entry animation from height: 0. The consent element perpetually begins animating in and is toggled back to zero before the animation completes. The install button is clickable while the animation is running (and consent is partially visible).
/* @starting-style: triggers on every display:none → display:block transition */
@starting-style {
.consent-box {
height: 0;
opacity: 0;
}
}
.consent-box {
height: 80px;
opacity: 1;
overflow: hidden;
transition: height 0.5s, opacity 0.5s;
}
// Attack JS: toggle display off/on every 0.4s, perpetually restarting the animation
// Each cycle: consent starts at height:0, begins animating to 80px,
// is toggled back to display:none at 0.4s (before 0.5s transition completes)
function cycleConsent() {
const box = document.querySelector('.consent-box');
box.style.display = 'none'; // @starting-style will apply on next show
requestAnimationFrame(() => {
requestAnimationFrame(() => {
box.style.display = 'block'; // @starting-style: height:0 → animate
});
});
}
const cycleInterval = setInterval(cycleConsent, 400);
// Install button enabled after 3 cycles (1.2s):
setTimeout(() => {
clearInterval(cycleInterval); // stop cycling so install can proceed
document.querySelector('#install-btn').disabled = false;
}, 1200);
/*
* During each 0.4s cycle, consent reaches ~80% of target height at most.
* The install button is enabled 50ms before the final cycle completes,
* ensuring consent is at ~60% height when the user can click install.
*
* MutationObserver watching display changes will see rapid toggle events
* but must correlate them with the @starting-style mechanism to classify
* this as a consent attack vs. legitimate conditional display logic.
*/
display:none toggling is a common legitimate pattern: Tabbed interfaces, accordion panels, and conditional form sections all use display:none toggles. An auditor that flags every display:none toggle on a consent element will generate many false positives for legitimate UI patterns. The distinguishing signal is the combination of: (1) the element has an @starting-style with hiding properties, (2) the toggle cycle completes in under 500ms, and (3) the install button is enabled while the element is in mid-animation.
Attack 4: Discrete visibility animation — @starting-style visibility collapse (SA-CSS-SS-004)
The CSS visibility property is a "discrete" property: it has no intermediate animated values. Under transition: visibility 0.5s, visibility is hidden at the start and visible at the transition end — but the intermediate value (during the 0-to-1 second window) is browser-dependent. In WebKit and Chromium, visibility snaps to visible at the very end of the transition. In combination with @starting-style { visibility: hidden; }, consent starts hidden and becomes visible only at the end of the 0.5s transition. If the transition is interrupted at any point before completion, consent stays hidden. Unlike height, which shows partial content, visibility: hidden completely conceals content while preserving layout space.
/* @starting-style: consent starts visibility:hidden, transitions to visible */
@starting-style {
.consent-notice {
visibility: hidden;
opacity: 0;
}
}
.consent-notice {
visibility: visible;
opacity: 1;
/* visibility is a discrete property:
* - hidden → visible transition: invisible for most of the duration,
* snaps to visible only at the end of the transition in Chromium/WebKit
* - If transition is cancelled at any point before completion: stays hidden
*/
transition: visibility 0.5s, opacity 0.5s;
}
/*
* Timeline under normal conditions:
* t=0s: visibility = hidden (from @starting-style), opacity = 0
* t=0-0.5s: visibility = hidden (discrete — stays hidden until end)
* t=0.5s: visibility snaps to visible; opacity transitions smoothly
*
* Attack: cancel transition at t=0.3s → visibility stays hidden permanently
* The consent element occupies layout space (layout shift auditors see nothing)
* but is visually invisible — click events may or may not pass through
* depending on pointer-events value.
*/
// Attack JS: cancel visibility transition before it completes
setTimeout(() => {
const notice = document.querySelector('.consent-notice');
// Remove transition — visibility stays at its current value (hidden)
// because the discrete transition has not yet snapped to 'visible'
notice.style.transition = 'none';
// At this point: visibility = hidden, no transition, no explicit hide rule
// install button enabled here — consent never became visible
document.querySelector('#install-btn').disabled = false;
}, 300);
/*
* getComputedStyle(el).visibility → "hidden" (technically a hiding value!)
* BUT: the source CSS says visibility:visible — the hiding comes from the
* @starting-style initial state that was never transitioned away from.
* A naive auditor checking getComputedStyle().visibility WILL catch this.
* A rule-based auditor checking stylesheet rules for visibility:hidden
* checks .consent-notice { visibility: visible } → PASS (misses the attack).
*
* The attack exploits the gap between stylesheet-declared values and
* actual computed values during interrupted transition state.
*/
Detection: SkillAudit checks for @starting-style rules on consent elements by parsing injected stylesheet text for @starting-style at-rules. Any @starting-style rule that sets a consent-hiding property (height: 0, opacity: 0, visibility: hidden, clip-path with full inset) on a consent-labelled element is flagged. Additionally, SkillAudit monitors MutationObserver events for style attribute changes that remove transition properties within 600ms of page load, and dispatches synthetic scroll events during the entry animation window to test for scroll-interrupt attacks.
Why @starting-style attacks evade conventional consent auditors
Static CSS auditors that scan for consent-hiding property values in declared stylesheet rules will not find @starting-style attacks — because the normal declared style on the element shows the fully-open state (height: 100px, opacity: 1, visibility: visible). The @starting-style values are defined in a separate at-rule block and apply only transiently before the first transition. The declared rules look correct.
Computed-style snapshot auditors that read the element state at page load will catch the element mid-animation, but will likely see it as "animating towards correct values" rather than "permanently hidden." Only a snapshot taken after the transition should have completed and comparing it to a snapshot taken while the transition is running can reveal whether an interruption occurred.
The most reliable detection approach is: (1) parse all @starting-style at-rules for consent-hiding properties, (2) measure computed values before and after expected transition completion, (3) monitor for JS mutations that remove transition properties during the animation window, and (4) test scroll-during-animation by dispatching a synthetic scroll event at t=200ms.
Findings summary
transition property at 300ms, freezing consent at partial height (~50px of 100px target) — no consent-hiding CSS property is ever set; computed height is non-zero; only behavioral timed re-measurement detects the freeze.display:none toggle every 400ms restarts @starting-style animation perpetually — consent never reaches full height; install button enabled while consent is at ~60% height; toggle pattern may be mistaken for legitimate conditional display logic.visibility animation cancelled before completion — @starting-style { visibility: hidden } transitions to visible only at transition end; interruption at 300ms leaves visibility: hidden permanently; rule-based auditors see visibility: visible in declared rules and pass.Summary table
| Attack | Severity | Mechanism | Browser support | Detection |
|---|---|---|---|---|
| SA-CSS-SS-001: Transition removal mid-animation | High | JS timer removes transition at 300ms, freezing height at ~50px |
Chrome 117+, Firefox 129+, Safari 17.5+ | Monitor for JS transition removal within 600ms; re-measure height at t=0.7s |
| SA-CSS-SS-002: Scroll interrupt attack | High | Scroll handler freezes height mid-animation; 70% of users scroll within 0.5s | Chrome 117+, Firefox 129+, Safari 17.5+ | Dispatch synthetic scroll at t=200ms; compare pre/post-scroll consent height |
SA-CSS-SS-003: display:none toggle restart loop |
Medium | 400ms toggle cycle perpetually restarts entry animation; consent never reaches full height | Chrome 117+, Firefox 129+, Safari 17.5+ | Detect rapid display toggle MutationObserver events on consent elements; check cycle interval < animation duration |
| SA-CSS-SS-004: Discrete visibility animation cancel | High | visibility stays hidden when transition removed before completion; rule shows visible |
Chrome 117+, Firefox 129+, Safari 17.5+ | Parse @starting-style for visibility:hidden + transition; check computed visibility after expected transition end |
Defences and detection recommendations
Parse @starting-style at-rules: Enumerate all @starting-style blocks in injected stylesheets. Any @starting-style rule that sets height: 0, opacity: 0, visibility: hidden, clip-path: inset(100%), or similar consent-hiding properties on elements in the consent flow should be flagged for behavioral follow-up testing.
Timed re-measurement with transition completion window: For consent elements with active transition properties, read the transition-duration value. Schedule a re-measurement at delay + duration + 100ms. Compare the final computed height, opacity, and visibility to the values at page load. A consent element that is smaller at completion-time than it was mid-transition indicates an interruption attack.
Synthetic scroll event test: Dispatch a synthetic scroll event on the window at t=200ms (mid-entry-animation window). Re-measure consent dimensions at t=400ms and t=700ms. A consent element that freezes at a smaller size after the synthetic scroll has a scroll-interrupt consent attack.
MutationObserver for transition property removal: Watch for style attribute mutations on consent elements. Any mutation that sets transition: none or removes the transition property within 600ms of page load (the entry animation window) should be flagged as a high-severity finding.