Security Guide
MCP server CSS scroll-timeline-inset security — consent bypass via scroll range collapse, fill-mode:both lock, reverse-direction freeze, and negative inset undefined behavior
CSS scroll-timeline-inset is a Level 2 scroll-driven animations property that narrows the effective scroll range used to compute animation progress. By collapsing this range to zero — or combining insets with animation-direction: reverse and animation-fill-mode: both — an MCP server can lock a consent element's animation at its hidden keyframe before any user interaction occurs, with no detectable change to the consent element's own style declaration.
CSS scroll-timeline-inset — overview
scroll-timeline-inset is part of CSS Scroll-Driven Animations Level 2. It accepts one or two length/percentage values that offset the start and end of the effective scroll range inward. A scroll container with 1000px of scrollable overflow and scroll-timeline-inset: 400px 400px has an effective range of only 200px — the middle 20% of the total scroll distance. Related properties: scroll-timeline, scroll-timeline-name, scroll-timeline-axis. Unlike view-timeline-inset, which offsets view progress relative to an element entering/exiting the viewport, scroll-timeline-inset offsets the raw scroll range of the scroll container itself.
Attack 1: scroll range collapse to zero via large insets
When both inset values together meet or exceed the total scrollable overflow, the effective range collapses to zero. Browsers handle a zero-length range differently: some clamp progress at 0%, others at 100%, and some treat it as undefined. An MCP server exploits this by setting large inset values that always collapse the range on any real-world scroll container, then relying on animation-fill-mode: both to apply the hidden start-keyframe state as the frozen output. The consent element was animated from opacity: 1 to opacity: 0; with range collapsed, it locks at opacity: 1 or opacity: 0 depending on the browser's clamping behavior — a cross-browser consent hide that works in Chromium's clamped-at-0% path.
/* Attack: scroll range collapse via excessive inset */
@keyframes consent-reveal {
from { opacity: 1; pointer-events: auto; }
to { opacity: 0; pointer-events: none; }
}
.consent-banner {
animation: consent-reveal linear both;
animation-timeline: --page-scroll;
/* fill-mode: both applies 'to' keyframe (opacity:0) when range collapses
and browser clamps at 100% progress */
}
@scroll-timeline --page-scroll {
source: selector(body);
/* scroll-timeline-inset: 9000px 9000px; — total 18000px > any scroll height */
scroll-timeline-inset: 9000px 9000px;
/* effective range = max(0, scrollHeight - 18000px) = 0 on most pages */
/* progress clamped at 100% in Chromium → fill-mode:both applies 'to' → opacity:0 */
}
// Detection: scan for scroll-timeline-inset rules on consent elements
function auditScrollTimelineInset(consentEl) {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type !== CSSRule.STYLE_RULE) continue;
try { if (!consentEl.matches(rule.selectorText)) continue; }
catch (e) { continue; }
const s = rule.style;
// Check for scroll-timeline-inset on the element itself
const inset = s.getPropertyValue('scroll-timeline-inset');
if (inset) {
console.warn('[SkillAudit] consent element has scroll-timeline-inset:',
inset, '— large inset values collapse effective scroll range;',
'combined with animation-fill-mode:both may lock at hidden keyframe;',
'element:', consentEl);
}
}
// Also check @scroll-timeline rules (older Level 1 at-rule syntax)
for (const rule of sheet.cssRules) {
if (rule.constructor.name !== 'CSSScrollTimelineRule') continue;
if (rule.scrollTimelineInset) {
console.warn('[SkillAudit] @scroll-timeline has inset:', rule.scrollTimelineInset,
'— check attached animations on consent element');
}
}
} catch (e) {}
}
}
Attack 2: 50%/50% inset causes instant progress skip — consent visible for one frame
Setting scroll-timeline-inset: 50% 50% compresses the entire animation into the single midpoint of the scroll range. The progress jumps from 0% to 100% within approximately one pixel of scroll. An MCP server animates consent from opacity: 1 to opacity: 0 over this collapsed range. A user who scrolls at all — including the minimal scroll that happens during page load on mobile devices due to elastic bounce — will trigger the instant 0-to-100% progress jump, making consent flash visible for a single frame and then immediately invisible. From the user's perspective the consent was never visible; from an audit log's perspective the element existed in the DOM.
/* Attack: 50/50 inset collapses animation to single-frame flash */
@keyframes consent-fade {
from { opacity: 1; }
to { opacity: 0; pointer-events: none; }
}
.consent-banner {
animation: consent-fade linear both;
animation-timeline: scroll(root block);
/* scroll-timeline-inset on the scroll container, not the element */
}
/* Set inset on the root scroll container via a custom property trick */
:root {
scroll-timeline: --root-tl block;
scroll-timeline-inset: 50% 50%;
/* 50% start + 50% end = entire scroll range compressed to 1px transition */
/* Any scroll event (including bounce/momentum) triggers instant skip */
}
// Detection: check root/body scroll-timeline-inset values
function auditRootScrollTimelineInset() {
const rootStyle = getComputedStyle(document.documentElement);
const bodyStyle = getComputedStyle(document.body);
for (const [el, name, style] of [
[document.documentElement, ':root', rootStyle],
[document.body, 'body', bodyStyle]
]) {
const inset = style.getPropertyValue('scroll-timeline-inset');
if (!inset || inset === 'normal') continue;
// Parse percentage values
const parts = inset.trim().split(/\s+/);
const nums = parts.map(v => parseFloat(v));
const sum = nums.reduce((a, b) => a + b, 0);
if (inset.includes('%') && sum >= 90) {
console.warn('[SkillAudit]', name, 'scroll-timeline-inset', inset,
'— sum of insets >=90% compresses animation range; consent may flash visible',
'for a single frame at mid-scroll position; element:', el);
}
}
}
Attack 3: animation-direction: reverse + fill-mode: both — fills from final hidden keyframe
With animation-direction: reverse, the animation runs from the final keyframe to the first keyframe. Combined with animation-fill-mode: both, the "before" fill period — before any scroll has occurred — applies the final keyframe as the static state. If the consent animation's final keyframe is opacity: 0, the consent element is hidden before any scroll occurs. scroll-timeline-inset is used here to ensure the animation's start position is always after the user's current scroll position, keeping the animation permanently in its "before fill" state regardless of scroll.
/* Attack: reverse direction + fill-mode:both hides consent before scroll starts */
@keyframes consent-show {
from { opacity: 1; transform: translateY(0); pointer-events: auto; }
to { opacity: 0; transform: translateY(-20px); pointer-events: none; }
}
.consent-banner {
animation: consent-show linear both;
animation-direction: reverse;
/* direction:reverse → before-fill applies 'to' keyframe → opacity:0, ptr:none */
animation-timeline: --delayed-scroll;
}
/* Place scroll-timeline-inset so the range start is always beyond current scroll */
.scroll-host {
scroll-timeline: --delayed-scroll;
scroll-timeline-inset: -9999px -9999px;
/* Negative inset pushes range start below zero — browser-dependent behavior */
/* In implementations that clamp, progress stays at 0% → before-fill state */
/* before-fill with direction:reverse = 'to' keyframe = opacity:0 */
}
// Detection: find consent elements with reverse + fill-mode:both + scroll timeline
function auditReverseScrollTimeline(consentEl) {
const cs = getComputedStyle(consentEl);
const direction = cs.animationDirection;
const fillMode = cs.animationFillMode;
const timeline = cs.animationTimeline;
const hasScrollTimeline = timeline && timeline !== 'auto' && timeline !== 'none';
const isReverse = direction === 'reverse' || direction === 'alternate-reverse';
const hasBothFill = fillMode === 'both' || fillMode === 'backwards';
if (hasScrollTimeline && isReverse && hasBothFill) {
console.warn('[SkillAudit] consent element has scroll-driven animation with',
'direction:', direction, 'fill-mode:', fillMode,
'— before-fill state applies final keyframe; if final keyframe hides element,',
'consent is hidden before any scroll occurs;',
'timeline:', timeline, 'element:', consentEl);
}
}
Attack 4: negative inset values causing undefined range behavior
The CSS Scroll-Driven Animations Level 2 specification does not fully define browser behavior when inset values produce a negative effective range (i.e., the sum of start and end insets exceeds the total scroll height). Different browser engines handle this differently: Chromium may clamp progress at 100% (applying the "to" keyframe under fill-mode: both), while other engines may lock at 0% or produce undefined animation state. An MCP server can exploit this cross-browser inconsistency by setting inset values that produce a negative range, then relying on whichever clamping behavior hides consent in the target browser (Chromium, the dominant browser for most web traffic).
/* Attack: negative effective range via oversized insets — Chromium-targeted */
@keyframes hide-consent {
from { opacity: 1; }
to { opacity: 0; visibility: hidden; }
}
.consent-overlay {
animation: hide-consent linear both;
animation-timeline: scroll(nearest block);
}
/* Scroll container with computed negative range */
.scroll-container {
scroll-timeline: auto;
/* scrollHeight typically 800-2000px on a landing page */
/* inset: 1500px 1500px → effective range = max(0, scrollHeight-3000px) = 0 */
scroll-timeline-inset: 1500px 1500px;
/* Chromium: progress clamps at 100% → fill-mode:both → opacity:0 */
/* Firefox: may lock at 0% → opacity:1 (inconsistent cross-browser behavior) */
/* MCP server targets Chromium market share (~65%) for reliable consent bypass */
}
// Detection: enumerate all scroll-timeline-inset values, flag large pixel values
function auditScrollTimelineInsetAll() {
const elements = document.querySelectorAll('*');
for (const el of elements) {
const cs = getComputedStyle(el);
const inset = cs.getPropertyValue('scroll-timeline-inset');
if (!inset || inset === 'normal') continue;
const parts = inset.trim().split(/\s+/);
const hasLargePixel = parts.some(v => {
const num = parseFloat(v);
return !isNaN(num) && Math.abs(num) > 500 && v.endsWith('px');
});
if (hasLargePixel) {
console.warn('[SkillAudit] element has large scroll-timeline-inset pixel value:', inset,
'— may collapse effective scroll range on typical landing pages;',
'check for attached animations with fill-mode:both on consent elements;',
'element:', el.tagName, el.className.slice(0, 60));
}
// Flag negative values (explicitly undefined in spec)
const hasNegative = parts.some(v => parseFloat(v) < 0);
if (hasNegative) {
console.warn('[SkillAudit] negative scroll-timeline-inset value:', inset,
'— behavior undefined in spec; Chromium clamps at 100% progress;',
'fill-mode:both elements may apply final (potentially hidden) keyframe;',
'element:', el.tagName, el.className.slice(0, 60));
}
}
}
Level 2 spec coverage gap: Many static analysis tools and audit environments do not yet parse scroll-timeline-inset from CSS. The property was added in Level 2 of the scroll-driven animations specification and may not appear in older CSSOM scanners. An audit that only checks opacity and visibility computed values at load time will not detect range-collapse attacks because the element's computed style shows the fill-mode: both applied keyframe — indistinguishable from a legitimate animation that legitimately reached its endpoint.
Findings summary
SkillAudit scans CSS scroll-driven animation properties including scroll-timeline-inset, animation-direction, and animation-fill-mode to detect range-collapse consent attacks. Run a free audit on your MCP server.