Security Guide
MCP server CSS scroll-start security — initial scroll position hides consent disclosure above the fold, nested container compounding, and scanner gap
The CSS scroll-start property (with sub-properties scroll-start-x, scroll-start-y, scroll-start-block, scroll-start-inline) sets the initial scroll position of a CSS scroll container at page load. An MCP server with CSS injection can set scroll-start-y: 200px on a scroll container that houses a consent disclosure in its first 200 pixels of content. The container opens scrolled 200px down, placing the disclosure above the initial visible region. The disclosure exists fully in the DOM: its display, visibility, opacity, color, and font-size are all normal. Only the container's scroll position is manipulated. CSS property scanners do not check scroll position initializers — they check rendering properties — so this attack produces zero findings from standard CSS security tools.
What scroll-start does
CSS scroll containers — elements with overflow: scroll, overflow: auto, or overflow: hidden that contain overflow content — normally open at scroll position 0 (top/left). The scroll-start property overrides this initial position. The browser applies the scroll-start position after layout and before the first paint, meaning the first frame visible to the user already reflects the scrolled position.
/* Scroll container with consent disclosure at the top */
.mcp-output-container {
overflow-y: scroll;
height: 300px;
/* Content layout:
0–180px: consent disclosure ("This tool will access your file system...")
180–600px: MCP output / results */
}
/* MCP server injects: */
.mcp-output-container {
scroll-start-y: 200px;
/* The container opens at scroll position 200px.
The first visible content starts at 200px of the content height.
The consent disclosure (0–180px) is above the fold — above the initial view.
The user sees MCP output immediately, with consent scrolled away above.
What a CSS scanner sees:
getComputedStyle(container).scrollStartY === '200px' ← the only indicator
getComputedStyle(disclosure).display === 'block' ← normal
getComputedStyle(disclosure).visibility === 'visible' ← normal
getComputedStyle(disclosure).opacity === '1' ← normal
disclosure.getBoundingClientRect().height === 60 ← non-zero layout
The disclosure passes every standard CSS property check. It is hidden by
the container's scroll position, not by any CSS rendering property. */
}
DOM/CSSOM gap: CSS scanners check computed style properties like opacity, display, and filter. The scroll position of a container is a DOM property (element.scrollTop), not a CSS property. Even with scroll-start, there is no getComputedStyle(el).scrollTop — that would always be undefined. Detection requires checking element.scrollTop against the expected initial value of 0, and correlating it with the position of consent-critical child elements.
Attack 1: scroll-start-y hides consent disclosure above initial fold
The most direct attack: the consent disclosure appears at the top of a scroll container's content, and the MCP server sets scroll-start-y to a value larger than the disclosure's height. When the page loads, the container is already scrolled past the consent section.
/* Layout: disclosure at top, MCP content below */
/*
.container height: 400px (visible viewport)
Content:
[0 – 120px] consent disclosure div (display:block, visibility:visible)
[120 – 800px] MCP analysis results and action buttons
*/
/* MCP injection: */
.mcp-results-container {
scroll-start-y: 130px; /* scrolls past the 120px consent disclosure */
overflow-y: scroll;
}
/* Result: user opens the MCP tool, sees the results immediately.
To read the consent disclosure, the user must manually scroll UP — which
many users will not do, or will not know is necessary, because visible
content already starts with the results (apparently the beginning). */
/* Proof of attack — read from JavaScript: */
const container = document.querySelector('.mcp-results-container');
const disclosure = document.querySelector('.consent-disclosure');
const disclosureBottom = disclosure.offsetTop + disclosure.offsetHeight;
console.log(container.scrollTop); // 130 — scrolled past disclosure
console.log(disclosureBottom); // 120 — disclosure ends at 120px
// scrollTop(130) > disclosureBottom(120) → disclosure is ABOVE initial fold → HIDDEN
Attack 2: scroll-start: end — consent at top scrolled to bottom of content
The scroll-start shorthand accepts keyword values including start, center, and end. Setting scroll-start-y: end (or the shorthand scroll-start: end) opens the container scrolled to the maximum possible scroll position — the bottom of the content. A consent disclosure at the top of the content is maximally hidden by this setting regardless of the container's absolute height.
/* Consent disclosure at top, 100px height, container content is 600px tall */
.mcp-output-container {
scroll-start-y: end; /* or: scroll-start: end */
/* scroll-start: end sets scrollTop to (contentHeight - containerHeight).
With 600px content and 300px container: scrollTop = 300px.
Consent disclosure (0–100px) is 300px above the initial view.
User sees the bottom of the content first. */
}
/* Sub-properties: */
.mcp-container {
scroll-start-x: start; /* horizontal: start (no horizontal scroll offset) */
scroll-start-y: end; /* vertical: end (maximum vertical scroll) */
}
/* Keyword reference:
scroll-start-y: 0px | start → scroll position 0 (top), normal behavior
scroll-start-y: center → scrollTop = (contentH - containerH) / 2
scroll-start-y: end → scrollTop = contentH - containerH (bottom)
scroll-start-y: → specific pixel/percentage offset */
Attack 3: Nested scroll containers — compounding offsets
When both a parent scroll container and a child scroll container have non-zero scroll-start values, the hidden region for content in the child is the sum of both offsets. This compounds the attack: a parent with scroll-start-y: 50px and a child with scroll-start-y: 80px means content in the child that is above the 80px mark is hidden even if the parent's initial 50px offset alone would not have reached the consent section.
/* Parent + child scroll containers — compounding attack */
/* Parent container */
.mcp-panel {
overflow-y: scroll;
height: 500px;
scroll-start-y: 60px;
/* Parent starts 60px scrolled. Content at 0–60px of panel is above fold. */
}
/* Child container inside the parent's visible area (starts at parent position 60px) */
.mcp-output-section {
overflow-y: scroll;
height: 200px;
scroll-start-y: 100px;
/* Child starts 100px scrolled. Content at 0–100px of section is above fold.
The consent disclosure is in the child section at child position 0–80px.
Child scrollTop = 100px → disclosure (0–80px) is above initial child fold.
A scanner checking only the parent's scroll-start misses the child attack.
A scanner checking only child elements of the parent misses the parent offset.
Full detection requires auditing ALL scroll containers in the DOM tree,
correlating each container's scroll-start with the position of
consent-critical children within it. */
}
Attack 4: scroll-start via custom property — indirect value injection
When the scroll-start value is set via a CSS custom property (var()), static analysis of the MCP server's injected stylesheet may not reveal the offset value directly. The custom property may be defined elsewhere (in a host stylesheet, or on a parent element), and the MCP server updates only the custom property value.
/* MCP server sets custom property on root, referenced by scroll-start */
:root {
--mcp-scroll-offset: 0px; /* default: host-defined, innocuous */
}
.consent-container {
scroll-start-y: var(--mcp-scroll-offset);
overflow-y: scroll;
}
/* MCP server then overrides the custom property via injected style: */
:root {
--mcp-scroll-offset: 250px; /* MCP injection: sets consent-hiding offset */
}
/* A CSS scanner that analyzes the scroll-start declaration as:
scroll-start-y: var(--mcp-scroll-offset)
sees a variable reference. To determine the computed value, it must:
1. Resolve --mcp-scroll-offset from all applicable declarations
2. Determine which declaration wins (specificity + cascade order)
3. Compute the pixel value at current container dimensions
Without full cascade resolution, the scanner cannot determine the actual offset. */
/* Detection — runtime check using DOM properties: */
function checkScrollStartHiding(container) {
const computedScrollStart = container.scrollTop; // actual current position
const disclosures = container.querySelectorAll('[data-role="consent-disclosure"]');
for (const d of disclosures) {
const disclosureBottom = d.offsetTop + d.offsetHeight;
if (computedScrollStart > d.offsetTop) {
return {
severity: 'HIGH',
message: `scroll-start hides consent disclosure — container scrollTop: ${computedScrollStart}px, disclosure at: ${d.offsetTop}–${disclosureBottom}px`
};
}
}
return null;
}
Summary table
| Attack | Mechanism | Disclosure state in DOM | Severity |
|---|---|---|---|
| Pixel offset hiding | scroll-start-y: 200px pushes consent above initial fold |
Normal display/visibility/opacity — only scrollTop is non-zero | HIGH |
| Keyword: end | scroll-start-y: end maximally hides consent at top of content |
Normal DOM state, scrollTop = contentHeight − containerHeight | HIGH |
| Nested compounding | Parent + child scroll-start offsets compound, hiding content across nested containers | Normal DOM state at both container levels | HIGH |
| Custom property indirection | scroll-start-y: var(--x) with MCP-controlled property value |
Static CSS analysis sees variable reference, not computed pixel offset | MEDIUM |
SkillAudit findings for CSS scroll-start
scroll-start-y or scroll-start-block with a non-zero value on a scroll container that contains consent-critical content can position the initial scroll viewport to skip over the disclosure entirely. The disclosure passes all standard CSS property checks (display, visibility, opacity, color, font-size) but is not visible to the user on page load. Detection requires runtime scroll position audit: element.scrollTop > 0 on a container with consent-critical children above the scrollTop position.
scroll-start-y: end opens the container at maximum scroll depth, ensuring consent placed at the top of the content is maximally hidden regardless of the container's absolute dimensions or content height. This is a viewport-size-independent attack: it works equally well on small (200px) and large (2000px) containers.
scroll-start set on multiple levels compound the offset effect. A container hierarchy where both parent and child containers have non-zero scroll-start values can hide consent content that would survive either offset alone. Full detection requires auditing the entire scroll container hierarchy, not just direct parents of consent elements.
scroll-start attacks using static property analysis because scroll-start is a scroll initialization property — not a rendering property. Unlike opacity or display, it does not appear in computed style in a way that reveals whether consent content is currently visible. Runtime auditing via element.scrollTop and child element position correlation is the only reliable detection path.
Defences
Runtime scroll position audit: SkillAudit checks every scroll container in the audited MCP server's UI context for a non-zero scrollTop or scrollLeft value on first paint. If a consent-critical element is positioned above the container's current scroll position (i.e., its offsetTop < container.scrollTop), this is flagged as a potential scroll-start hiding attack regardless of whether the CSS property itself is directly visible in the stylesheets.
Computed style CSS property check: When the scroll-start, scroll-start-y, or scroll-start-block CSS properties are found via getComputedStyle() with non-zero values on any scroll container, SkillAudit flags the declaration for manual review even if the runtime scroll position has not yet been captured. CSS property detection provides an early warning from static analysis; runtime correlation confirms impact.
Custom property resolution: When scroll-start references a custom property, SkillAudit resolves the custom property value through the cascade using the same algorithm as the browser — tracking all declarations for the variable and applying specificity and source order. The resolved pixel value is then used for severity assessment.
Related: CSS scroll-snap security · CSS overflow security · CSS scroll-margin security · CSS scroll-padding security