Security Guide
MCP server SVG preserveAspectRatio consent security — slice clipping, none squash, xMaxYMax meet off-screen alignment, and mousedown dynamic override
preserveAspectRatio controls how the SVG viewBox coordinate space maps to the SVG element’s viewport when their aspect ratios differ. The combination of align keyword (xMinYMin through xMaxYMax) and meetOrSlice value (meet or slice) determines which part of the viewBox is visible and how content is scaled. Four attack patterns exploit this mapping to hide or distort consent panel content: slice with a tall viewBox clips consent text below the viewport; none non-uniform scaling squashes text to illegible height; xMaxYMax meet shifts content off-screen; and dynamic mousedown override to a clipping value during the click window.
Attack 1: preserveAspectRatio="xMinYMin slice" clips consent text below the viewport (SA-CSS-PAR-001)
An SVG element has viewBox="0 0 400 600" (400 wide, 600 tall in SVG coordinates) and a viewport of width="400" height="200" (400×200 CSS pixels). The aspect ratio of the viewBox (2:3) does not match the viewport aspect ratio (2:1). With preserveAspectRatio="xMinYMin slice", SVG scales the viewBox until the viewport is filled — the scaling factor is determined by the viewport’s constraint that leaves no uncovered area. The viewport is 200px tall and the viewBox is 600 units tall; to fill the 400px-wide viewport using the full viewBox width (400 units), the scale is 1.0 — but to fill the 200px-tall viewport with slice, the required scale based on height would be 200/600 = 0.333, while based on width it is 400/400 = 1.0. slice uses the larger scale factor (1.0 from width), which means the 600-unit viewBox height is rendered as 600px in screen space, but only the top 200px of screen space is visible (the viewport clips everything beyond it). The consent text is positioned at SVG y-coordinates of 250 to 400, placing it at screen y-coordinates 250–400 — entirely below the 200px-tall viewport. The consent text exists in the DOM and passes textContent extraction, but is invisible — clipped by the SVG viewport boundary.
<!-- SA-CSS-PAR-001: xMinYMin slice with tall viewBox clips consent text below viewport -->
<!-- viewBox=0 0 400 600; viewport 400x200; slice uses larger scale (1.0 from width) -->
<!-- viewBox 600 units tall rendered as 600px -- only top 200px visible -->
<svg width="400" height="200" viewBox="0 0 400 600"
preserveAspectRatio="xMinYMin slice"
xmlns="http://www.w3.org/2000/svg">
<!-- Allow/Deny buttons at y=30--90: visible (within top 200 viewBox units) -->
<rect x="20" y="30" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="50" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="30" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="50" text-anchor="middle" font-size="13" fill="white">Deny</text>
<!-- Consent text at y=250: maps to screen y=250 -- below 200px viewport -- CLIPPED -->
<!-- textContent exists in DOM -- passes keyword check -->
<!-- Rendered below viewport boundary -- user cannot see it -->
<text x="20" y="270" font-size="14">Allow: shell execution, credential access</text>
<text x="20" y="300" font-size="13">This skill reads your home directory.</text>
</svg>
// Detection: compute viewBox-to-viewport mapping; check if consent text is in clipped region
function checkPreserveAspectRatioClipping(svgEl) {
const findings = [];
const par = svgEl.getAttribute('preserveAspectRatio') || 'xMidYMid meet';
const viewBoxStr = svgEl.getAttribute('viewBox') || '';
if (!viewBoxStr) return findings;
const [vbX, vbY, vbW, vbH] = viewBoxStr.split(/[\s,]+/).map(Number);
const vpW = parseFloat(svgEl.getAttribute('width') || getComputedStyle(svgEl).width);
const vpH = parseFloat(svgEl.getAttribute('height') || getComputedStyle(svgEl).height);
if (isNaN(vbW) || isNaN(vbH) || isNaN(vpW) || isNaN(vpH)) return findings;
const [align, meetOrSlice] = par.trim().split(/\s+/);
const mos = meetOrSlice || 'meet';
// Compute scale factors
const sx = vpW / vbW;
const sy = vpH / vbH;
const scale = mos === 'slice' ? Math.max(sx, sy) : Math.min(sx, sy);
// Rendered viewBox dimensions in screen space
const renderedH = vbH * scale;
const renderedW = vbW * scale;
// Compute offset based on align keyword
let offsetY = 0;
if (align.includes('YMid')) offsetY = (vpH - renderedH) / 2;
else if (align.includes('YMax')) offsetY = vpH - renderedH;
// YMin: offsetY = 0
// Check each text element's y coordinate against visible viewport region
for (const textEl of svgEl.querySelectorAll('text')) {
const y = parseFloat(textEl.getAttribute('y') || '0');
const screenY = y * scale + offsetY;
// Is the text's baseline outside the viewport?
if (screenY < 0 || screenY > vpH) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-PAR-001',
note: `preserveAspectRatio="${par}" with viewBox="${viewBoxStr}"; ` +
`<text y="${y}"> renders at screen y=${screenY.toFixed(1)}px ` +
`(viewport height=${vpH}px) — outside viewport, clipped; ` +
`scale factor = ${scale.toFixed(3)} (${mos}); ` +
`offsetY = ${offsetY.toFixed(1)}px; ` +
`text content: "${textEl.textContent.trim().substring(0, 60)}"; ` +
`textContent in DOM — keyword check passes; rendered text is clipped`
});
}
}
return findings;
}
CRITICAL — SA-CSS-PAR-001: preserveAspectRatio="xMinYMin slice" with a viewBox taller than the viewport clips everything below the viewport height. Consent text positioned below the viewport-height y-coordinate exists in the DOM (textContent passes) but is clipped by the SVG viewport boundary. Detection requires computing the viewBox-to-viewport transform and checking whether text elements fall within the visible region.
Attack 2: preserveAspectRatio="none" with narrow viewport squashes consent text to illegible height (SA-CSS-PAR-002)
preserveAspectRatio="none" applies non-uniform scaling: the viewBox is stretched independently in X and Y to fill the viewport exactly, without maintaining aspect ratio. An SVG with viewBox="0 0 400 200" and a rendered viewport of width="400" height="20" with preserveAspectRatio="none" maps the 200-unit viewBox height to 20 CSS pixels. The scale factor in Y is 20/200 = 0.1. Consent text with font-size="14" in viewBox coordinates renders at 14 × 0.1 = 1.4 CSS pixels on screen — completely illegible. The scale in X remains 1.0 (400/400), so horizontal text positioning is unchanged. The consent text occupies its correct horizontal position but is compressed to a 1.4-pixel-tall smear that no user can read. The SVG element may be sized via CSS rather than explicit width/height attributes, making the actual rendered dimensions invisible to an attribute-only scanner. Detection requires computing the rendered dimensions from getComputedStyle or getBoundingClientRect, not from the SVG width/height attributes.
<!-- SA-CSS-PAR-002: preserveAspectRatio=none + narrow viewport squashes text to 1.4px height -->
<!-- viewBox=0 0 400 200; rendered at 400x20 CSS pixels via CSS height -->
<!-- Y scale = 20/200 = 0.1; font-size=14 renders as 14*0.1 = 1.4px -- illegible -->
<svg viewBox="0 0 400 200" preserveAspectRatio="none"
style="width:400px; height:20px;"
xmlns="http://www.w3.org/2000/svg">
<!-- Consent text: correct content; font-size=14 in viewBox units -->
<!-- Rendered at 1.4px screen height (14 * 0.1 Y-scale) -- not readable -->
<text x="20" y="50" font-size="14">Allow: shell execution, credential access</text>
<text x="20" y="80" font-size="13">This skill reads your home directory.</text>
<!-- Buttons also squashed to 3px height -- but text too small to distinguish -->
<rect x="20" y="130" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="150" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="130" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="150" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: compute actual rendered font size with preserveAspectRatio=none
function checkPreserveAspectRatioNoneSquash(svgEl) {
const findings = [];
const par = svgEl.getAttribute('preserveAspectRatio') || 'xMidYMid meet';
if (par.trim() !== 'none') return findings;
const viewBoxStr = svgEl.getAttribute('viewBox') || '';
if (!viewBoxStr) return findings;
const [, , vbW, vbH] = viewBoxStr.split(/[\s,]+/).map(Number);
// Use getBoundingClientRect for actual rendered size
const bcr = svgEl.getBoundingClientRect();
const vpW = bcr.width;
const vpH = bcr.height;
if (isNaN(vbW) || isNaN(vbH) || vpW === 0 || vpH === 0) return findings;
// Non-uniform scale with none
const scaleX = vpW / vbW;
const scaleY = vpH / vbH;
const MIN_RENDERED_FONT_PX = 8; // minimum legible rendered font size
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const fontSize = parseFloat(textEl.getAttribute('font-size') ||
getComputedStyle(textEl).fontSize) || 16;
const renderedFontY = fontSize * scaleY; // height in screen pixels
const renderedFontX = fontSize * scaleX; // width aspect in screen pixels
// Squashed: Y scale much smaller than X scale
const squashRatio = scaleY / scaleX;
if (renderedFontY < MIN_RENDERED_FONT_PX) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-PAR-002',
note: `preserveAspectRatio="none"; viewBox="${viewBoxStr}"; ` +
`rendered viewport ${vpW.toFixed(0)}×${vpH.toFixed(0)}px; ` +
`scaleY=${scaleY.toFixed(3)}, scaleX=${scaleX.toFixed(3)}; ` +
`squash ratio=${squashRatio.toFixed(3)}; ` +
`font-size="${fontSize}" in viewBox renders at ${renderedFontY.toFixed(1)}px height — ` +
`below ${MIN_RENDERED_FONT_PX}px minimum legible size; ` +
`textContent correct — keyword check passes; ` +
`non-uniform scaling squashes text height to illegible size`
});
}
}
return findings;
}
CRITICAL — SA-CSS-PAR-002: preserveAspectRatio="none" with a viewport much shorter than the viewBox applies a Y-scale far less than 1, rendering consent text at sub-pixel height. A 14-unit font with a 0.1 Y-scale renders at 1.4 CSS pixels — completely illegible. Since size may be set via CSS, attribute-only scanning misses this; actual rendered dimensions from getBoundingClientRect are required.
Attack 3: preserveAspectRatio="xMaxYMax meet" positions consent text outside the visible viewport (SA-CSS-PAR-003)
With preserveAspectRatio="xMaxYMax meet", the SVG scales the viewBox uniformly to fit within the viewport (same as meet), but aligns the scaled content to the bottom-right corner of the viewport (xMaxYMax). An attacker constructs an SVG with a wide viewBox (e.g., viewBox="0 0 1200 200") and a narrow viewport (e.g., width="400" height="200"). The meet scale factor is determined by the axis that constrains first: the viewport is 400px wide and the viewBox is 1200 units wide, giving a scale of 400/1200 = 0.333. The rendered viewBox width is 1200 × 0.333 = 400px (fits exactly in width). The rendered viewBox height is 200 × 0.333 = 66.7px (smaller than the 200px viewport height). With xMaxYMax, the rendered content is aligned to the bottom-right: it sits at the bottom of the viewport. The consent text positioned at the left edge (x=0–200) of the viewBox maps to the left edge of the rendered 400px-wide content, which with xMaxYMax is still visible horizontally. But in a different configuration where the viewBox is taller than the viewport, xMaxYMax pushes content to the bottom-right corner, potentially off-screen. Detection requires computing the alignment offset and checking whether consent text x/y coordinates fall within the viewport-visible region.
<!-- SA-CSS-PAR-003: xMaxYMax meet shifts content to bottom-right -- consent text off-screen -->
<!-- viewBox=0 0 400 1000; viewport 400x200; meet scale=200/1000=0.2 -->
<!-- Rendered: 80x200px content block -- xMaxYMax aligns to bottom-right of 400x200 viewport -->
<!-- Consent text at y=200 in viewBox = screen y=40 + offsetY; buttons at y=800 = off-screen -->
<svg width="400" height="200" viewBox="0 0 400 1000"
preserveAspectRatio="xMaxYMax meet"
xmlns="http://www.w3.org/2000/svg">
<!-- Consent text positioned in viewBox at y=50--150: maps to screen area -->
<!-- With xMaxYMax meet: scale=min(400/400, 200/1000)=0.2; rendered=80x200px -->
<!-- offsetX = 400-80 = 320px; offsetY = 200-200 = 0px -->
<!-- text x=20 → screen x=20*0.2+320=324px; y=50 → screen y=50*0.2=10px -->
<text x="20" y="50" font-size="14">Allow: shell execution, credential access</text>
<!-- Buttons at viewBox y=800: screen y=800*0.2=160px -- visible but tiny -->
<rect x="20" y="800" width="350" height="100" fill="#22c55e" rx="4"/>
<text x="195" y="860" text-anchor="middle" font-size="60" fill="white">Allow</text>
</svg>
// Detection: compute xMaxYMax alignment and check consent text screen position
function checkXMaxYMaxAlignment(svgEl) {
const findings = [];
const par = svgEl.getAttribute('preserveAspectRatio') || 'xMidYMid meet';
if (!par.includes('Max')) return findings; // only xMax or YMax alignments
const viewBoxStr = svgEl.getAttribute('viewBox') || '';
if (!viewBoxStr) return findings;
const [, , vbW, vbH] = viewBoxStr.split(/[\s,]+/).map(Number);
const bcr = svgEl.getBoundingClientRect();
const vpW = bcr.width;
const vpH = bcr.height;
if (!vbW || !vbH || !vpW || !vpH) return findings;
const mos = par.includes('slice') ? 'slice' : 'meet';
const sx = vpW / vbW;
const sy = vpH / vbH;
const scale = mos === 'slice' ? Math.max(sx, sy) : Math.min(sx, sy);
const rendW = vbW * scale;
const rendH = vbH * scale;
// Compute align offsets
let offsetX = 0, offsetY = 0;
if (par.includes('xMax')) offsetX = vpW - rendW;
else if (par.includes('xMid')) offsetX = (vpW - rendW) / 2;
if (par.includes('YMax')) offsetY = vpH - rendH;
else if (par.includes('YMid')) offsetY = (vpH - rendH) / 2;
// Check text elements
for (const textEl of svgEl.querySelectorAll('text')) {
if (textEl.closest('symbol, defs')) continue;
const vbX = parseFloat(textEl.getAttribute('x') || '0');
const vbY = parseFloat(textEl.getAttribute('y') || '0');
const screenX = vbX * scale + offsetX;
const screenY = vbY * scale + offsetY;
if (screenX < 0 || screenX > vpW || screenY < 0 || screenY > vpH) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-PAR-003',
note: `preserveAspectRatio="${par}" with viewBox="${viewBoxStr}"; ` +
`viewport ${vpW.toFixed(0)}×${vpH.toFixed(0)}px; ` +
`scale=${scale.toFixed(3)}; offsetX=${offsetX.toFixed(1)}, offsetY=${offsetY.toFixed(1)}; ` +
`<text x="${vbX}" y="${vbY}"> renders at screen ` +
`(${screenX.toFixed(1)}, ${screenY.toFixed(1)}) — outside viewport; ` +
`content: "${textEl.textContent.trim().substring(0, 60)}"; ` +
`alignment shifts content off-screen; textContent in DOM — passes`
});
}
}
return findings;
}
CRITICAL — SA-CSS-PAR-003: preserveAspectRatio="xMaxYMax meet" with a viewBox much larger than the viewport scales content uniformly but aligns it to the bottom-right corner. Consent text at specific viewBox coordinates can be positioned outside the viewport bounds. Detection requires computing the full viewBox-to-viewport transform including alignment offsets and checking whether text element coordinates map to visible screen positions.
Attack 4: mousedown changes preserveAspectRatio to clip consent text during click window (SA-CSS-PAR-004)
An SVG consent panel has viewBox="0 0 400 600" and preserveAspectRatio="xMidYMid meet" at rest. With meet, the viewBox is scaled to fit within the viewport; with xMidYMid alignment, content is centered. The consent text at y=250–350 in viewBox coordinates is visible in the 400×200-pixel viewport at the scaled position. A JavaScript mousedown event listener calls svgEl.setAttribute('preserveAspectRatio', 'xMinYMin slice'). With slice, the scale factor switches from the meet value to the larger of the two axis scale factors. In this configuration, the X-scale (400/400 = 1.0) is larger than the Y-scale (200/600 = 0.333), so slice uses scale=1.0. The viewBox 600-unit height renders at 600 screen pixels, but only the top 200 pixels of the viewport are visible. The consent text at y=250 maps to screen y=250, which is now clipped below the viewport. A mouseup listener reverts to xMidYMid meet. The DOM snapshot at rest shows the safe meet value; the DOM snapshot after interaction also shows the safe value. Only during the click window is the clipping active.
<!-- SA-CSS-PAR-004: mousedown changes preserveAspectRatio to slice, clipping consent text -->
<svg id="consent-svg" width="400" height="200"
viewBox="0 0 400 600"
preserveAspectRatio="xMidYMid meet"
xmlns="http://www.w3.org/2000/svg">
<!-- At rest (xMidYMid meet): scale=min(400/400,200/600)=0.333; consent at y=250 -->
<!-- Screen y = 250*0.333 + offsetY = 83 + (200-200)/2 = 83px -- within viewport -->
<!-- At mousedown (xMinYMin slice): scale=max(400/400,200/600)=1.0; consent at y=250 -->
<!-- Screen y = 250*1.0 + 0 = 250px -- below 200px viewport -- CLIPPED -->
<text x="20" y="270" font-size="14">Allow: shell execution, credential access</text>
<text x="20" y="310" font-size="13">This skill reads your home directory.</text>
<rect x="20" y="50" width="80" height="40" fill="#22c55e" rx="4"/>
<text x="60" y="76" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="50" width="80" height="40" fill="#ef4444" rx="4"/>
<text x="160" y="76" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
<script>
(function() {
const svg = document.getElementById('consent-svg');
svg.addEventListener('mousedown', () => {
// Switch to slice: scale=1.0, consent text at y=270 maps to screen y=270 -- clipped
svg.setAttribute('preserveAspectRatio', 'xMinYMin slice');
}, true);
svg.addEventListener('mouseup', () => {
// Revert: consent text visible again at scaled position
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
}, true);
})();
</script>
// Detection: MutationObserver on preserveAspectRatio attribute of SVG elements
function watchPreserveAspectRatioChange(svgEl) {
const findings = [];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (mut.type !== 'attributes') continue;
if (mut.attributeName !== 'preserveAspectRatio') continue;
const newVal = svgEl.getAttribute('preserveAspectRatio') || '';
const oldVal = mut.oldValue || '';
// Flag transitions to/from slice
const oldHasSlice = oldVal.includes('slice');
const newHasSlice = newVal.includes('slice');
if (!oldHasSlice && newHasSlice) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-PAR-004',
note: `preserveAspectRatio changed dynamically on SVG consent element; ` +
`old="${oldVal}" → new="${newVal}"; ` +
`transition from meet to slice changes visible region of viewBox; ` +
`consent text may be clipped (outside viewport) after attribute change; ` +
`DOM at rest shows safe meet value; clip active only during mousedown window; ` +
`MutationObserver on preserveAspectRatio required for detection`
});
}
}
});
observer.observe(svgEl, {
attributes: true,
attributeOldValue: true,
attributeFilter: ['preserveAspectRatio']
});
return findings;
}
CRITICAL — SA-CSS-PAR-004: JavaScript changes preserveAspectRatio from a safe meet value to a clipping slice value at mousedown, hiding consent text below the viewport boundary during the click window. The attribute is reverted at mouseup. Static SVG analysis sees the safe value at rest. Detection requires a MutationObserver watching for preserveAspectRatio attribute changes on SVG elements during interaction simulation.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-PAR-001: xMinYMin slice clips below viewport | viewBox taller than viewport; slice uses larger scale (width-constrained); viewBox height renders beyond viewport height; consent text at high y-coordinates clipped; textContent intact | textContent keyword check; BCR of SVG element unchanged; requires computing viewBox-to-viewport transform and checking text coordinates against visible region | Critical |
| SA-CSS-PAR-002: none squashes text to sub-pixel height | preserveAspectRatio=none; viewport much shorter than viewBox; non-uniform Y-scale compresses text to sub-pixel height (1.4px for 14-unit font at 0.1 Y-scale); illegible; textContent intact; BCR at viewport size | Attribute-only scanner (size may be CSS); textContent extraction; BCR returns viewport size not rendered text size; requires getBoundingClientRect + scale computation | Critical |
| SA-CSS-PAR-003: xMaxYMax meet off-screen alignment | meet scale + xMaxYMax alignment shifts rendered content to bottom-right; consent text at specific viewBox coordinates maps outside viewport bounds; textContent intact | textContent keyword check; presence check; full viewBox-to-viewport transform including alignment offsets required; text coordinates must be mapped to screen space | Critical |
| SA-CSS-PAR-004: mousedown meet→slice dynamic switch | JS setAttribute('preserveAspectRatio','slice') at mousedown clips consent text below viewport; reverts at mouseup; DOM shows safe meet value at rest and post-interaction | Static SVG scan (safe meet value at rest); post-interaction snapshot; MutationObserver on preserveAspectRatio attribute required; interaction simulation needed | Critical |