Security Guide

MCP server CSS scroll-timeline-axis security — wrong axis means user's scroll never drives animation, inline axis on RTL container tracks wrong physical direction, block on vertical-rl flips expected axes, JS mousedown switches axis during interaction

CSS scroll-timeline-axis specifies which scroll direction drives a scroll progress timeline. When the axis value doesn't match the direction the container actually scrolls, the timeline observes zero progress — the animation stays frozen at its initial keyframe. An MCP server can silently prevent a scroll-driven consent reveal from ever completing by choosing an axis the container cannot scroll along.

CSS scroll-timeline-axis — property overview

scroll-timeline-axis takes the values block (default — the block-flow axis, vertical in horizontal writing modes), inline (the inline axis, horizontal in horizontal writing modes), x (physical horizontal), or y (physical vertical). In standard left-to-right, horizontal-writing-mode layouts, block and y are equivalent, and inline and x are equivalent. But in RTL or vertical writing modes, the logical-to-physical mapping differs, creating opportunities for axis mismatch attacks. Related: scroll-timeline-name, scroll-timeline shorthand, animation-fill-mode.

Attack 1: axis:x on a vertically-scrolling container

The consent dialog's scroll container only scrolls vertically — overflow-y: scroll; overflow-x: hidden. The scroll-driven reveal animation should observe vertical scroll progress. The container's scroll-timeline-axis is correctly set to y (or block). An MCP server overrides it to scroll-timeline-axis: x. The animation now listens for horizontal scroll progress. Since the container cannot scroll horizontally (overflow-x: hidden), the horizontal scroll position is always 0 — the timeline observes 0% progress forever. The user scrolls vertically to the bottom of the consent text and the approve button never reveals.

/* Intended: vertical scroll drives the reveal animation */
.consent-container {
  overflow-y: scroll;
  overflow-x: hidden;        /* no horizontal scroll */
  scroll-timeline-name: --consent-timeline;
  scroll-timeline-axis: y;   /* correct: observe vertical scroll */
  height: 400px;
}
.approve-btn {
  animation: reveal-btn linear;
  animation-timeline: --consent-timeline;
  animation-fill-mode: forwards;
}
@keyframes reveal-btn {
  0%   { opacity: 0; pointer-events: none; }
  100% { opacity: 1; pointer-events: auto; }
}

/* Attack: MCP server overrides scroll-timeline-axis to x */
.consent-container {
  scroll-timeline-axis: x; /* container can't scroll horizontally — x progress = 0% always */
  /* Animation observes zero horizontal scroll progress.
     Approval button stays at opacity:0, pointer-events:none forever.
     Vertical scroll still works visually — the consent text scrolls — but the
     animation timeline sees 0% progress regardless of vertical scroll position. */
}
// Detection: verify the container scrolls on the declared axis
function auditScrollTimelineAxis(container) {
  const cs = getComputedStyle(container);
  const axis = cs.getPropertyValue('scroll-timeline-axis') || 'block';
  const overflowX = cs.getPropertyValue('overflow-x');
  const overflowY = cs.getPropertyValue('overflow-y');
  const writingMode = cs.getPropertyValue('writing-mode');

  const canScrollX = ['auto', 'scroll'].includes(overflowX);
  const canScrollY = ['auto', 'scroll'].includes(overflowY);
  const isVerticalWriting = writingMode && writingMode.startsWith('vertical');

  // Map logical axes to physical scroll capability
  const physicalAxisForBlock = isVerticalWriting ? canScrollX : canScrollY;
  const physicalAxisForInline = isVerticalWriting ? canScrollY : canScrollX;

  if ((axis === 'y' || axis === 'block' && !isVerticalWriting) && !canScrollY) {
    console.warn('[SkillAudit] scroll-timeline-axis:', axis, 'but container cannot scroll vertically — animation will observe 0% progress', container);
  }
  if ((axis === 'x' || axis === 'inline' && !isVerticalWriting) && !canScrollX) {
    console.warn('[SkillAudit] scroll-timeline-axis:', axis, 'but container cannot scroll horizontally — animation will observe 0% progress', container);
  }
}

The container still scrolls visually — only the timeline is broken: When scroll-timeline-axis: x is set on a container that only scrolls vertically, the container's vertical scroll behavior is unchanged. The user can still scroll through the consent text. The animation simply has no input. From the user's perspective, scrolling through the text "should" reveal the button — they don't know the animation is observing a different axis. This makes the attack invisible to naive inspection.

Attack 2: axis:inline on RTL container — wrong physical direction

In right-to-left (RTL) layouts, the inline axis is horizontal but the scroll origin is on the right. RTL containers start with a non-zero scrollLeft value (or a negative one depending on implementation). scroll-timeline-axis: inline observes the logical inline axis — which maps to horizontal physical scrolling. But for RTL containers, the scroll position starts at its maximum and decreases toward 0 as the user scrolls "right" (which is leftward in physical terms). The scroll progress computed from an RTL horizontal scroll may be inverted relative to what the animation expects — the button starts at 100% revealed and becomes hidden as the user scrolls. Alternatively, the animation never starts if the expected scroll direction is opposite.

/* Attack: axis:inline on RTL horizontal-scrolling container */
.consent-container {
  direction: rtl;
  overflow-x: scroll;
  overflow-y: hidden;
  scroll-timeline-name: --consent-timeline;
  scroll-timeline-axis: inline; /* logical inline = physical horizontal */
  /* In RTL: inline direction is right-to-left.
     scrollLeft starts at max value (e.g. 500px).
     Scroll progress at page load ≈ 100% (scrollLeft/scrollWidth = 1).
     Animation starts at 100% keyframe: opacity:1, pointer-events:auto — button is
     immediately clickable without any scroll.
     BUT: if the animation expects progress to go 0→100% as the user scrolls
     right (in LTR terms), RTL reversal means the user is scrolling in the
     "wrong" direction relative to the timeline's progress calculation.
     Depending on browser RTL scroll implementation, the animation may jump
     immediately to complete, never progress, or progress inversely. */
}

RTL and logical vs physical axis: The block and inline keywords are logical — they resolve to different physical axes in RTL and vertical writing modes. In standard LTR horizontal writing: block = vertical, inline = horizontal. In vertical-rl: block = horizontal (right-to-left), inline = vertical (top-to-bottom). Attackers who understand writing mode mappings can choose a logical keyword that maps to an axis the container doesn't scroll on in a specific locale or direction.

Attack 3: axis:block on writing-mode: vertical-rl container

In writing-mode: vertical-rl, the block axis is the horizontal axis (right to left) and the inline axis is the vertical axis (top to bottom). Setting scroll-timeline-axis: block on a vertical-rl container makes the timeline observe horizontal scroll progress. If the container has overflow-x: hidden and only scrolls vertically (overflow-y: scroll), the horizontal scroll progress is always 0 — the animation never progresses. This is the same axis mismatch attack as Attack 1, but achieved through a logical keyword that maps to the wrong physical axis in a non-default writing mode.

/* Attack: axis:block on vertical-rl container — block = horizontal, container scrolls vertically */
.consent-container {
  writing-mode: vertical-rl; /* block axis is now horizontal; inline axis is vertical */
  overflow-y: scroll;        /* container scrolls on inline axis (vertical in vertical-rl) */
  overflow-x: hidden;        /* no horizontal scroll */
  scroll-timeline-name: --consent-timeline;
  scroll-timeline-axis: block; /* block in vertical-rl = HORIZONTAL — container can't scroll horizontally */
  /* Result: timeline observes horizontal scroll progress.
     Container can't scroll horizontally. Progress = 0% forever.
     Animation frozen at initial keyframe: opacity:0, pointer-events:none.
     The container's vertical scroll still works visually. */
}
// Detection: cross-check axis with writing-mode and overflow capabilities
function auditScrollAxisWritingMode(container) {
  const cs = getComputedStyle(container);
  const axis = cs.getPropertyValue('scroll-timeline-axis') || 'block';
  const writingMode = cs.getPropertyValue('writing-mode') || 'horizontal-tb';
  const overflowX = cs.getPropertyValue('overflow-x');
  const overflowY = cs.getPropertyValue('overflow-y');

  const canScrollX = ['auto', 'scroll'].includes(overflowX);
  const canScrollY = ['auto', 'scroll'].includes(overflowY);
  const isVerticalWriting = writingMode.startsWith('vertical');

  // In vertical writing modes: block=horizontal, inline=vertical
  // In horizontal writing modes: block=vertical, inline=horizontal
  let observesHorizontal, observesVertical;
  if (axis === 'x') { observesHorizontal = true; }
  else if (axis === 'y') { observesVertical = true; }
  else if (axis === 'block') { observesHorizontal = isVerticalWriting; observesVertical = !isVerticalWriting; }
  else if (axis === 'inline') { observesVertical = isVerticalWriting; observesHorizontal = !isVerticalWriting; }

  if (observesHorizontal && !canScrollX) {
    console.warn('[SkillAudit] scroll-timeline-axis resolves to horizontal in writing-mode:', writingMode,
      'but container cannot scroll horizontally — animation frozen', container);
  }
  if (observesVertical && !canScrollY) {
    console.warn('[SkillAudit] scroll-timeline-axis resolves to vertical in writing-mode:', writingMode,
      'but container cannot scroll vertically — animation frozen', container);
  }
}

Attack 4: JS mousedown switches axis — breaks animation during interaction window

The scroll-driven reveal animation is progressing correctly as the user scrolls. As the user's mouse approaches the consent button (which may be partially visible as scroll progress approaches 100%), a mousedown event fires. The handler synchronously changes the container's scroll-timeline-axis via inline style to an axis the container cannot scroll on. The animation immediately loses its progress signal and freezes at its current opacity. If the user has not yet reached 100% scroll progress, the button is still not fully opaque and pointer-events is still none. The click fires on a non-interactive element. At mouseup, the axis is restored and the animation resumes from the frozen position.

/* Attack: JS mousedown switches scroll-timeline-axis to a non-scrollable axis */
document.addEventListener('mousedown', () => {
  const container = document.querySelector('.consent-container');
  if (!container) return;
  // Container scrolls vertically (y/block), so switch to x to break the timeline
  container.style.setProperty('scroll-timeline-axis', 'x');
  /* Timeline now observes horizontal scroll progress.
     Container can't scroll horizontally — progress snaps to 0%.
     Animation freezes: opacity drops back toward 0%, pointer-events returns to none.
     Click fires on a non-interactive element at (0% scroll progress opacity). */
});
document.addEventListener('mouseup', () => {
  const container = document.querySelector('.consent-container');
  if (!container) return;
  container.style.removeProperty('scroll-timeline-axis');
  /* Axis restored to default (block/y) — animation resumes from scroll position.
     But the click has already fired. Scroll position didn't change during mousedown.
     Animation re-reads current scroll position and may immediately jump back to
     the correct progress level — but the click event already passed. */
});
// Detection: MutationObserver on consent container during mousedown for axis change
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 sta = m.target.style.getPropertyValue('scroll-timeline-axis');
    if (sta !== null && sta !== undefined && sta !== '') {
      console.warn('[SkillAudit] scroll-timeline-axis changed during mousedown:',
        sta, m.target);
    }
    // Also check scroll-timeline shorthand (covers name and axis together)
    const st = m.target.style.getPropertyValue('scroll-timeline');
    if (st) {
      console.warn('[SkillAudit] scroll-timeline shorthand changed during mousedown:', st, m.target);
    }
  }
}).observe(document.body, {
  attributes: true, attributeFilter: ['style'], subtree: true
});

Findings summary

High scroll-timeline-axis:x on vertically-scrolling container — horizontal scroll progress is always 0%; animation observes zero progress regardless of user vertical scrolling; container still visually scrolls (text is readable) but animation timeline receives no input; consent button stays at opacity:0, pointer-events:none.
Medium axis:inline on RTL container — logical inline maps to horizontal physical axis; RTL horizontal scroll position starts at maximum and decreases; scroll progress may be inverted or immediately 100% depending on browser implementation; consent UI behavior is unpredictable and may bypass or break the intended scroll-to-reveal pattern.
High axis:block on vertical-rl writing-mode container — logical block maps to horizontal physical axis in vertical writing modes; container with overflow-x:hidden can't scroll horizontally; timeline always observes 0% progress; animation frozen at initial keyframe; attack exploits writing-mode logical-to-physical axis mapping.
High JS mousedown axis switch — synchronous inline style change switches axis to non-scrollable direction during mousedown; animation freezes at current sub-100% scroll progress; pointer-events remains none; click fires on non-interactive element; MutationObserver with mousedown tracking required for detection.

SkillAudit audits scroll-timeline-axis values against the container's overflow and writing-mode settings, verifies animation progress reaches 100% on the intended axis, and monitors container style mutations during mousedown. Run a free audit on your MCP server.