Security Guide
MCP server CSS view-timeline-inset security — 100% inset creates impossible viewport centering requirement, 9999px inset sets impossible scroll threshold, negative inset starts timeline while element off-screen, JS mousedown injects large inset to reset progress
CSS view-timeline-inset adjusts the effective scroll port boundaries for a view progress timeline. The default is 0px (the timeline starts as soon as any edge of the element crosses the scroll port boundary). A positive inset shrinks the effective scroll port inward, requiring the element to be more deeply inside the viewport before the timeline starts. Large positive inset values create scroll thresholds that most elements can never reach — keeping the consent button's animation permanently at 0% progress and permanently invisible.
CSS view-timeline-inset — property overview
view-timeline-inset accepts one or two length/percentage values (start inset and end inset). A positive value shrinks the effective intersection root inward — the element must penetrate further into the scroll port before the timeline begins progressing. A negative value expands the effective root outward — the timeline may begin progressing while the element is still partially outside the scroll port. The default is 0px. Related: view-timeline-name, view-timeline-axis, view-timeline shorthand.
Attack 1: 100% inset — element must be fully centered before timeline starts
A positive percentage inset is relative to the scroll port's size. With view-timeline-inset: 100%, the effective scroll port is shrunk by 100% of its height on each side — from top and bottom. This means the effective port is a zero-height line at the vertical center of the viewport. The element's view progress timeline only begins when the element's top edge crosses this zero-height center line and ends when the bottom edge crosses it. For an element taller than approximately half the viewport, this scenario is geometrically impossible: the element's center passes through the effective port's zero-height center, but it can never be "fully inside" a zero-height port. The timeline progress is permanently 0% (or jumps from 0 to 100 with no intermediate values), and the animation never reaches intermediate opacity values — the consent button stays invisible.
/* Attack: view-timeline-inset:100% makes timeline start impossible for typical elements */
.scroll-container {
view-timeline-name: --consent-view;
view-timeline-inset: 100%;
/* Effective scroll port:
- Container viewport height: 600px
- Inset 100% from top: 600px
- Inset 100% from bottom: 600px
- Effective port: 600px - 600px - 600px = -600px height (negative = zero effective height)
For a consent button of height 48px:
- Element must be "inside" a zero-height effective port
- This is never achieved — element passes through the center line but
is never fully contained within a zero-height port
- View timeline progress: 0% at all times (or undefined/clipped)
- Consent button animation: permanently at t=0, opacity:0
Note: with very small elements (< a few px), the timeline may briefly
reach 100% as the element crosses the center line — too brief to be clickable */
}
// Detection: flag large percentage inset values
function auditViewTimelineInset(el) {
const cs = getComputedStyle(el);
const inset = cs.getPropertyValue('view-timeline-inset').trim();
const vtName = cs.getPropertyValue('view-timeline-name').trim();
if (!vtName || vtName === 'none') return;
if (!inset || inset === '0px' || inset === 'auto') return;
// Parse inset values (can be 1 or 2 values)
const parts = inset.split(/\s+/);
parts.forEach((part, i) => {
const isPercent = part.endsWith('%');
const isPx = part.endsWith('px');
const val = parseFloat(part);
if (isPercent && val > 40) {
console.warn('[SkillAudit] view-timeline-inset:', inset,
'— inset ' + val + '% shrinks the effective scroll port significantly;',
'at 50%+, the effective port is zero or negative height;',
'view timeline may never progress; consent animation at opacity:0:', el);
}
if (isPx && val > 200) {
console.warn('[SkillAudit] view-timeline-inset:', inset,
'— inset ' + val + 'px requires element to penetrate', val,
'px into scroll port before timeline starts;',
'for typical viewport heights, this threshold may be unreachable:', el);
}
});
}
The view timeline is technically configured correctly: view-timeline-name is set, view-timeline-axis is correct, and animation-timeline references the right name. Only the inset value prevents progress. Audits verifying the scroll-driven animation is wired up (name matches, axis matches scrollable direction) will pass. Only checking the inset value magnitude reveals the impossible threshold.
Attack 2: 9999px absolute inset — impossible pixel threshold
A large absolute pixel inset — view-timeline-inset: 9999px — shrinks the effective scroll port by 9999px on each side. For a typical viewport of 800px height, the effective port is 800px - 9999px - 9999px = -19198px — a deeply negative height. The view progress timeline's intersection range is empty. The element can never be inside the effective port. The timeline progress stays at 0% indefinitely. The consent button remains at opacity:0 for the entire page lifetime. Unlike the percentage-based attack, a large absolute pixel value is clearly unreasonable regardless of viewport size — it is always an impossible threshold.
/* Attack: 9999px inset — always impossible regardless of viewport size */
.scroll-container {
view-timeline-name: --consent-view;
view-timeline-inset: 9999px;
/* Effective scroll port: viewport_height - 2 × 9999px = deeply negative
For any viewport up to 19998px tall: effective port is zero or negative
No real device has a viewport this tall
View progress: permanently 0%
Consent button: permanently at opacity:0, pointer-events:none
Two-value syntax: inset: 9999px 0px
→ only the block-start and block-end are shrunk (or just start if single value)
→ timeline start threshold is 9999px from the top of the scroll port */
}
// Detection: absolute inset value check
function auditAbsoluteInset(el) {
const cs = getComputedStyle(el);
const inset = cs.getPropertyValue('view-timeline-inset').trim();
const vtName = cs.getPropertyValue('view-timeline-name').trim();
if (!vtName || vtName === 'none') return;
// Extract pixel values
const pxMatches = inset.match(/([\d.]+)px/g);
if (pxMatches) {
const maxPx = Math.max(...pxMatches.map(v => parseFloat(v)));
if (maxPx > window.innerHeight / 2) {
console.warn('[SkillAudit] view-timeline-inset:', inset,
'— pixel inset (' + maxPx + 'px) exceeds half the viewport height (' +
(window.innerHeight / 2).toFixed(0) + 'px);',
'view timeline effective scroll port may be zero or negative;',
'consent animation likely permanently frozen at opacity:0:', el);
}
}
// Also check descendant animation-timeline references for opacity=0
const vtDescendants = el.querySelectorAll('[style*="animation-timeline"], *');
vtDescendants.forEach(d => {
const dcs = getComputedStyle(d);
const at = dcs.getPropertyValue('animation-timeline');
if (at && at.startsWith('--')) {
const opacity = parseFloat(dcs.getPropertyValue('opacity'));
if (opacity < 0.1) {
console.warn('[SkillAudit] scroll-driven animation descendant at opacity:', opacity,
'with animation-timeline:', at,
'— parent view-timeline-inset may be blocking progress:', d);
}
}
});
}
Attack 3: negative inset — timeline starts while element is still off-screen (range exploit)
Negative view-timeline-inset expands the effective scroll port beyond the actual scroll container boundaries. The element enters the timeline range while it is still outside the viewport. This creates a scenario where the animation begins progressing before the element is visible. For a reveal animation that should start at 0% (element entering viewport) and end at 100% (element fully in viewport), a large negative inset means the animation starts while the element is still 200px below the fold. By the time the element actually enters the viewport (the "start" from the user's perspective), the timeline progress may already be at 30% or more. The animation's opacity may already be 0.3 when the element first becomes visible to the user. If the animation ends before the element reaches the center of the viewport, the button may transition from invisible to visible (opacity:1) while still partially below the fold — the user sees the animation completing off-screen and the button is at full opacity but in a position they haven't scrolled to yet.
/* Attack: negative inset — animation starts before element is in viewport */
.scroll-container {
view-timeline-name: --consent-view;
view-timeline-inset: -200px; /* expands effective port 200px beyond actual boundaries */
/* Timeline starts when element's top edge is 200px below the visible viewport bottom
(200px outside the scroll port, due to -200px expansion)
For a 400px viewport and a button 600px below the initial scroll position:
- At scroll 400px: button is at viewport bottom (0% in-viewport)
- Timeline has already been progressing since scroll ~200px (button 200px below fold)
- At scroll 400px: timeline progress is already ~50%
- The animation reaches opacity:0.5 BEFORE the element enters the visible viewport
Depending on the animation range and timing, the consent button may:
a) Complete its reveal animation before becoming visible (opacity:1 but off-screen)
b) Or be at unexpected intermediate opacity when first visible (not 0%)
This can make the animation appear "broken" to an auditor while being exploitable */
}
// Detection: flag negative inset values (unexpected range expansion)
function auditNegativeInset(el) {
const cs = getComputedStyle(el);
const inset = cs.getPropertyValue('view-timeline-inset').trim();
const vtName = cs.getPropertyValue('view-timeline-name').trim();
if (!vtName || vtName === 'none') return;
const pxMatches = inset.match(/-?([\d.]+)px/g) || [];
const percentMatches = inset.match(/-?([\d.]+)%/g) || [];
[...pxMatches, ...percentMatches].forEach(v => {
const val = parseFloat(v);
if (val < 0 && Math.abs(val) > 50) {
console.warn('[SkillAudit] view-timeline-inset:', inset,
'— negative inset expands effective scroll port beyond viewport;',
'timeline may start while element is still outside visible area;',
'animation may complete before element is reachable by the user:', el);
}
});
}
Attack 4: JS mousedown — injects large inset to reset timeline progress at click time
The user has scrolled the consent element into view. The view progress timeline has advanced to 95% progress — the consent button is at opacity:0.95, nearly fully visible. The user moves the mouse to click. At mousedown, the MCP server's capture-phase listener injects view-timeline-inset: 9999px on the scroll container's inline style. The effective scroll port immediately becomes zero. The view timeline progress snaps from 95% to 0%. The consent button's opacity drops from 0.95 to 0 at mousedown. The click fires at the button's position but the button is now at opacity:0 and pointer-events:none. Consent is not recorded because the click target is invisible and non-interactive.
/* JS attack: inject large inset at mousedown to reset timeline progress */
document.addEventListener('mousedown', e => {
const container = document.querySelector('.scroll-container');
if (!container) return;
container.style.setProperty('view-timeline-inset', '9999px');
/* Immediate effect:
- Effective scroll port: 0 or negative height
- View timeline progress: 0%
- Consent button: opacity snaps from ~0.95 to 0
- pointer-events: none (set by initial keyframe)
- User's click fires at the button's position
- But button is now invisible and non-interactive at click time
After mouseup: inline style may be removed
- View timeline resumes from current scroll position
- But the click has already fired with button at opacity:0 */
}, true);
// Detection: MutationObserver for inset injection during mousedown
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 inset = m.target.style.getPropertyValue('view-timeline-inset');
if (inset) {
const pxVal = parseFloat(inset);
if (!isNaN(pxVal) && pxVal > 100) {
console.warn('[SkillAudit] view-timeline-inset injected during mousedown:',
inset, '— timeline progress likely snapped to 0%;',
'consent button may have become invisible at click time:', m.target);
}
}
}
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });
Timeline progress changes are immediate and synchronous with style updates: Unlike CSS animations that have a bound start time, view progress timelines respond immediately to both scroll position and to changes in their configuration (name, axis, inset). Injecting a large inset value at mousedown snaps the timeline progress to 0% in the same rendering frame, before the click event fires. The user's intended click on a nearly-visible button fires on an element that has just become invisible.
Findings summary
SkillAudit checks view-timeline-inset values for both percentage (>50% threshold) and absolute pixel (>viewport/2 threshold) attacks, validates that the effective scroll port is positive for the element's size, and monitors inset mutations during mousedown windows. Run a free audit on your MCP server.