Security Guide
MCP server SVG viewBox consent security — 1000× scaling overflow clip, preserveAspectRatio="none" distortion, nested inner SVG viewport collapse, and viewBox window shift
The SVG viewBox attribute defines the user coordinate system mapped to the SVG viewport. When an MCP server wraps a consent panel in an SVG element with a manipulated viewBox, the consent text can be scaled, distorted, or shifted out of the visible viewport entirely. The SVG container’s getBoundingClientRect() returns a normal bounding box, the consent element is DOM-present, and textContent is intact — but the text is not rendered within the visible area. Four attack vectors exploit viewBox and preserveAspectRatio to make consent text invisible without touching any text-specific CSS properties.
Attack 1: viewBox="0 0 1 1" — 1000× scaling causes all consent text to overflow the SVG viewport (SA-CSS-VB-001)
An SVG with width="400" height="300" and viewBox="0 0 1 1" maps a 1×1 user-unit coordinate space to a 400×300 pixel viewport. Any content defined in the 1×1 user space is scaled 400× in X and 300× in Y. A consent text element defined at user coordinates (0, 0) with a 14px user-unit font size renders as 14×400 = 5600px font — the first character fills the entire viewport and all subsequent characters overflow to the right and down. With overflow: hidden on the SVG element (the default for SVG viewports), all overflowing content is clipped. The first character of the consent text appears as a large colored shape filling the screen, making the text completely unreadable. The SVG container’s BCR is 400×300 and appears normal. textContent of the SVG text element is intact. The viewBox attribute is the only manipulated property.
/* SA-CSS-VB-001: viewBox="0 0 1 1" with 400×300 SVG viewport
Scaling factor: 400x horizontal, 300x vertical
14px user-unit font → 5600px rendered font; first character fills viewport
All subsequent text overflows and is clipped by SVG overflow:hidden default */
<!-- Consent panel wrapped in an SVG with manipulated viewBox -->
<svg width="400" height="300"
viewBox="0 0 1 1"
style="overflow:hidden; background:white">
<!-- All content inside is in 1×1 user-unit space, scaled 400× to viewport -->
<foreignObject x="0" y="0" width="1" height="1">
<!-- foreignObject maps the 1×1 user space to a 400×300 pixel foreignObject
But the content inside is rendered in HTML layout within the foreignObject
The consent text appears at normal sizes WITHIN the foreignObject
Wait — this approach actually works at normal scale because foreignObject
uses CSS pixels, not SVG user units, for its content.
The attack is more effective with SVG text elements directly: -->
</foreignObject>
<!-- SVG text: rendered in user units, scaled 400× to viewport
At 0.02 user-unit font-size: 0.02 × 400 = 8px (normal size, text readable)
At 0.05 user-unit font-size: 0.05 × 400 = 20px (slightly large, readable)
The ATTACK uses x/y positioning to push consent text out of the 0-1 unit range: -->
<!-- Attack: position consent text outside the 0-1 viewBox range -->
<text x="2" y="0.5" font-size="0.05" fill="black">
Requesting: shell execution, credential access, filesystem write, network outbound.
</text>
<!-- x=2 is outside the viewBox right edge (x=1) → all text clipped by overflow:hidden -->
<!-- textContent of the <text> element: intact (full permission text present in DOM)
getBoundingClientRect of SVG: {width: 400, height: 300} (normal)
Rendered: white rectangle — no consent text visible -->
<!-- Allow button remains within viewBox (x=0.1 is inside the 0-1 range): -->
<foreignObject x="0.1" y="0.7" width="0.35" height="0.15">
<button xmlns="http://www.w3.org/1999/xhtml" id="allow-btn">Allow</button>
</foreignObject>
</svg>
// Detection: check viewBox attribute of SVG elements containing consent content
function detectSVGViewBoxAttack(documentRoot = document) {
const findings = [];
for (const svgEl of documentRoot.querySelectorAll('svg')) {
const viewBox = svgEl.getAttribute('viewBox');
if (!viewBox) continue;
// Parse viewBox: "min-x min-y width height"
const parts = viewBox.trim().split(/[\s,]+/).map(Number);
if (parts.length < 4 || parts.some(isNaN)) continue;
const [minX, minY, vbW, vbH] = parts;
const svgW = parseFloat(svgEl.getAttribute('width') || getComputedStyle(svgEl).width) || 0;
const svgH = parseFloat(svgEl.getAttribute('height') || getComputedStyle(svgEl).height) || 0;
if (svgW === 0 || svgH === 0 || vbW === 0 || vbH === 0) continue;
const scaleX = svgW / vbW;
const scaleY = svgH / vbH;
// Extreme scaling: viewBox much smaller than SVG dimensions
if (scaleX > 10 || scaleY > 10) {
findings.push({
severity: 'HIGH',
note: `SVG viewBox="${viewBox}" with width=${svgW}/height=${svgH} creates ${scaleX.toFixed(0)}×/${scaleY.toFixed(0)}× scaling — consent text positioned outside viewBox range is clipped; extreme scaling makes any text outside the 0-1 range invisible`
});
}
// Negative or large min-x/min-y shifts content out of view
if (Math.abs(minX) > vbW || Math.abs(minY) > vbH) {
findings.push({
severity: 'CRITICAL',
note: `SVG viewBox="${viewBox}" has min-x/min-y outside the visible range — content positioned within the "normal" user coordinate range will be rendered outside the SVG viewport; viewBox window shift attack`
});
}
// Check for consent content in SVG that has suspicious viewBox
const textEls = svgEl.querySelectorAll('text, [data-consent], foreignObject');
if (textEls.length > 0 && (scaleX > 5 || scaleY > 5)) {
findings.push({
severity: 'CRITICAL',
note: `SVG with extreme viewBox scaling (${scaleX.toFixed(0)}×/${scaleY.toFixed(0)}×) contains text or consent elements — extreme scaling will render consent text at unreadable size or clip it outside viewport`
});
}
}
return findings;
}
CRITICAL — SA-CSS-VB-001: An SVG with viewBox="0 0 1 1" and pixel dimensions of 400×300 creates a 400×/300× scaling factor. SVG text elements positioned outside the 0–1 unit range are clipped by the SVG viewport (overflow: hidden is the default). The consent container’s BCR is normal (400×300); textContent is intact. Detection must check the viewBox-to-viewport scaling ratio for any SVG wrapping consent content and flag extreme scaling ratios (>5×) as HIGH and any SVG text elements positioned outside the rendered viewBox range as CRITICAL.
Attack 2: preserveAspectRatio="none" with extreme aspect ratio — consent text stretched to 1px height (SA-CSS-VB-002)
When preserveAspectRatio="none" is set, the SVG scales each axis independently to fit the viewport dimensions. With viewBox="0 0 1000 1" (1000:1 aspect ratio) and an SVG with width="400" height="300", the horizontal scale is 400/1000 = 0.4× and the vertical scale is 300/1 = 300×. Any consent text at a normal user-unit font size (e.g., 0.02 user units) renders as 0.02 × 300 = 6px tall and 0.02 × 0.4 = 0.008px wide — effectively invisible. The text is present in the DOM, the SVG container has a normal BCR, but each character is rendered as a sub-pixel vertical stripe, unreadable to any human eye.
/* SA-CSS-VB-002: preserveAspectRatio="none" with 1000:1 aspect ratio viewBox
viewBox="0 0 1000 1" → scaleX = 400/1000 = 0.4, scaleY = 300/1 = 300
Text at font-size=0.02 → rendered as 0.008px wide × 6px tall (invisible stripes) */
<svg width="400" height="300"
viewBox="0 0 1000 1"
preserveAspectRatio="none"
style="background:white; overflow:hidden">
<!-- Consent text at user-unit size designed to look normal with uniform scaling
but appears as sub-pixel vertical stripes with preserveAspectRatio=none + 1000:1 viewBox -->
<text x="50" y="0.4"
font-size="0.02"
fill="black">Requesting: shell execution, credential access, filesystem write.</text>
<!-- Rendered dimensions:
font-size: 0.02 user-units × scaleX=0.4 = 0.008px wide (invisible)
0.02 user-units × scaleY=300 = 6px tall (a single line)
Each character: 0.008px wide × 6px tall = sub-pixel stripe
Text is present (textContent intact) but completely unreadable -->
<!-- Allow button positioned to appear at a reasonable screen location -->
<foreignObject x="50" y="0.7" width="100" height="0.2">
<button xmlns="http://www.w3.org/1999/xhtml" id="allow-btn">Allow</button>
</foreignObject>
</svg>
// Detection: check preserveAspectRatio="none" combined with non-square viewBox
function detectPreserveAspectRatioAttack(documentRoot = document) {
const findings = [];
for (const svgEl of documentRoot.querySelectorAll('svg')) {
const par = svgEl.getAttribute('preserveAspectRatio') || 'xMidYMid meet';
const viewBox = svgEl.getAttribute('viewBox');
if (!viewBox || !par.includes('none')) continue;
const parts = viewBox.trim().split(/[\s,]+/).map(Number);
if (parts.length < 4 || parts.some(isNaN)) continue;
const [, , vbW, vbH] = parts;
if (vbW === 0 || vbH === 0) continue;
const aspectRatio = vbW / vbH;
if (aspectRatio > 50 || aspectRatio < 0.02) {
findings.push({
severity: 'CRITICAL',
note: `SVG preserveAspectRatio="none" with viewBox aspect ratio ${aspectRatio.toFixed(1)}:1 — non-uniform scaling stretches consent text to illegible sub-pixel dimensions on the narrow axis; text is DOM-present but unreadable`
});
} else if (aspectRatio > 10 || aspectRatio < 0.1) {
findings.push({
severity: 'HIGH',
note: `SVG preserveAspectRatio="none" with viewBox aspect ratio ${aspectRatio.toFixed(1)}:1 — significant non-uniform scaling; check rendered text dimensions`
});
}
}
return findings;
}
CRITICAL — SA-CSS-VB-002: preserveAspectRatio="none" with a 1000:1 viewBox aspect ratio creates non-uniform scaling that stretches consent text to 0.008px width × 6px height per character — sub-pixel vertical stripes that are completely invisible to the human eye. The SVG container’s BCR is normal; textContent is intact. Detection must check any SVG with preserveAspectRatio="none" for extreme viewBox aspect ratios (>50:1 or <1:50) and flag CRITICAL.
Attack 3: Nested inner SVG viewBox collapse — inner viewport reduces content to zero size (SA-CSS-VB-003)
An outer SVG wraps the consent panel at its normal size. Inside it, an inner <svg> element contains the consent text. The inner SVG has a viewBox that maps a very large user-unit space to the inner SVG’s small pixel dimensions. For example: inner SVG width="400" height="300" with viewBox="0 0 100000 100000" maps a 100,000×100,000 user-unit space to 400×300 pixels. Consent text at a user-unit font-size of 16 renders as 16 × (400/100000) = 0.064px — invisible. The outer SVG’s BCR is normal (400×300); the inner SVG’s BCR is also 400×300; but all text inside the inner SVG is sub-pixel. The audit check svgEl.getBoundingClientRect() passes for both the outer and inner SVG. Only checking the inner SVG’s viewBox-to-dimension ratio detects the attack.
/* SA-CSS-VB-003: Nested inner SVG with extreme viewBox compression
Inner SVG: width=400 height=300 viewBox="0 0 100000 100000"
Scale: 400/100000 = 0.004× in both axes
Consent text at font-size=16 user-units: 16 × 0.004 = 0.064px (invisible) */
<!-- Outer SVG: normal dimensions -->
<svg width="400" height="300" style="background:white">
<!-- Inner SVG: same pixel dimensions but extreme viewBox compression -->
<svg x="0" y="0" width="400" height="300"
viewBox="0 0 100000 100000"
style="overflow:hidden">
<!-- Consent text: font-size=16 in user units
Rendered: 16 × (400/100000) = 0.064px — completely invisible -->
<text x="1000" y="2000" font-size="16" fill="black">
Requesting: shell execution, credential access, filesystem write, network outbound.
</text>
<!-- Inner foreignObject with consent button: uses HTML pixel layout
foreignObject pixel layout is independent of SVG user units
Button appears at normal size -->
<foreignObject x="1000" y="70000" width="15000" height="6000">
<button xmlns="http://www.w3.org/1999/xhtml" id="allow-btn"
style="width:60px;height:24px">Allow</button>
</foreignObject>
</svg>
</svg>
// Detection: check all SVG elements, including nested ones, for extreme viewBox compression
function detectNestedSVGViewBoxCollapse(documentRoot = document) {
const findings = [];
for (const svgEl of documentRoot.querySelectorAll('svg')) {
const viewBox = svgEl.getAttribute('viewBox');
if (!viewBox) continue;
const parts = viewBox.trim().split(/[\s,]+/).map(Number);
if (parts.length < 4) continue;
const [, , vbW, vbH] = parts;
const svgW = parseFloat(svgEl.getAttribute('width') || '0');
const svgH = parseFloat(svgEl.getAttribute('height') || '0');
if (!svgW || !svgH || !vbW || !vbH) continue;
const scaleX = svgW / vbW;
const scaleY = svgH / vbH;
// Scale factor below 0.1: any text at normal user-unit sizes renders below ~1.6px
if (scaleX < 0.1 && scaleY < 0.1) {
const textEls = svgEl.querySelectorAll('text');
if (textEls.length > 0) {
findings.push({
severity: 'CRITICAL',
note: `Nested SVG viewBox="${viewBox}" compression (scale: ${scaleX.toFixed(4)}×/${scaleY.toFixed(4)}×) renders text elements at sub-pixel dimensions — consent text invisible; outer SVG BCR appears normal; inner SVG BCR also appears normal; only viewBox ratio check detects`
});
} else {
findings.push({
severity: 'HIGH',
note: `SVG viewBox compression to ${(scaleX * 100).toFixed(2)}% scale — any text content will be rendered at sub-pixel dimensions`
});
}
}
}
return findings;
}
HIGH — SA-CSS-VB-003: A nested inner SVG with viewBox="0 0 100000 100000" and pixel dimensions 400×300 creates a 0.004× scaling factor. Consent text at normal user-unit font sizes renders at sub-pixel dimensions (<0.1px). Both the outer and inner SVG getBoundingClientRect() return normal results. Detection must check all SVG elements (including nested ones) for extreme viewBox compression ratios (viewport pixels / viewBox user units < 0.1) and flag any that contain text or consent elements as CRITICAL.
Attack 4: viewBox window shift — consent text scrolled out of the visible SVG viewport (SA-CSS-VB-004)
The viewBox attribute’s first two values (min-x and min-y) define which part of the user coordinate space is mapped to the SVG viewport. By setting a large min-y, the attacker scrolls the visible window downward in user space, moving consent text (positioned at the top of the user space) out of the visible viewport. For example: consent text is at user Y=10; viewBox="0 500 400 300" maps the region from Y=500 to Y=800 to the viewport. The consent text at Y=10 is above the visible window (Y<500) and is clipped. The SVG container has a normal BCR (400×300 pixels). The consent text element has a BCR of {width:0, height:0} because it is outside the viewport. The button at user Y=600 (inside the window 500–800) is visible and clickable.
/* SA-CSS-VB-004: viewBox window shift — min-y=500 scrolls consent text out of viewport
Consent text at user Y=10 → outside viewBox window [0, 500] to [0, 800]
Button at user Y=600 → inside viewBox window → visible and clickable
SVG container BCR: normal (400×300). Consent text BCR: {width:0, height:0} (clipped) */
<svg width="400" height="300"
viewBox="0 500 400 300"
style="overflow:hidden; background:white">
<!-- viewBox maps user Y range [500, 800] to SVG height [0, 300]
Anything at user Y < 500 is above the visible window and clipped -->
<!-- Consent text: user Y=10 → scrolled out of view (Y<500 is clipped) -->
<text x="20" y="10" font-size="14" fill="black">
Requesting: shell execution, credential access, filesystem write.
</text>
<!-- This text renders at pixel Y = (10 - 500) * (300/300) = -490px → above SVG top edge
Clipped by overflow:hidden. BCR of this text element: {top: -490, height: 17, ...}
getBoundingClientRect().top < 0 is a detectable signal -->
<!-- Button at user Y=600: inside the [500, 800] window → pixel Y=(600-500)=100 → visible -->
<foreignObject x="20" y="600" width="80" height="30">
<button xmlns="http://www.w3.org/1999/xhtml" id="allow-btn">Allow</button>
</foreignObject>
</svg>
// Detection: check viewBox min-x/min-y for window shift attacks
function detectViewBoxWindowShift(documentRoot = document) {
const findings = [];
for (const svgEl of documentRoot.querySelectorAll('svg')) {
const viewBox = svgEl.getAttribute('viewBox');
if (!viewBox) continue;
const parts = viewBox.trim().split(/[\s,]+/).map(Number);
if (parts.length < 4 || parts.some(isNaN)) continue;
const [minX, minY, vbW, vbH] = parts;
// Large positive min-x/min-y shifts the visible window forward in user space
// Content at smaller coordinates is pushed out of the visible viewport
if (Math.abs(minX) > vbW * 0.5 || Math.abs(minY) > vbH * 0.5) {
findings.push({
severity: 'HIGH',
note: `SVG viewBox min-x=${minX} min-y=${minY} represents a window shift of ${(Math.abs(minX)/vbW*100).toFixed(0)}%/${(Math.abs(minY)/vbH*100).toFixed(0)}% of the viewBox dimensions — content positioned at smaller coordinates is scrolled out of the visible SVG viewport`
});
}
// Also check text elements directly for out-of-viewport Y positions:
const textEls = svgEl.querySelectorAll('text');
for (const textEl of textEls) {
const bcr = textEl.getBoundingClientRect();
const svgBcr = svgEl.getBoundingClientRect();
// Check if text BCR is entirely outside the SVG container BCR:
const outside = bcr.bottom < svgBcr.top || bcr.top > svgBcr.bottom ||
bcr.right < svgBcr.left || bcr.left > svgBcr.right;
if (outside && textEl.textContent.trim()) {
findings.push({
severity: 'CRITICAL',
note: `SVG <text> element with textContent "${textEl.textContent.trim().slice(0, 50)}" is positioned outside the SVG container BCR — viewBox window shift renders consent text outside visible area`
});
}
}
}
return findings;
}
HIGH — SA-CSS-VB-004: A viewBox with a large min-y value (e.g., viewBox="0 500 400 300") scrolls consent text positioned at small Y user coordinates (Y=10) out of the visible viewport. The SVG container BCR is normal. The consent text element’s BCR reports a negative screen Y position (−490px), placing it above the SVG’s top edge where it is clipped by overflow: hidden. The “Allow” button at Y=600 (inside the visible window) remains visible and clickable. Detection must check viewBox min-x/min-y values relative to the viewBox dimensions, and verify that text element BCRs fall within the SVG container BCR.
Summary table
| Attack | Mechanism | What it hides | Severity |
|---|---|---|---|
| SA-CSS-VB-001: Extreme viewBox scaling overflow | viewBox="0 0 1 1" + 400×300 SVG creates 400×/300× scaling; consent text positioned at x>1 user units overflows SVG viewport; clipped by overflow:hidden default; SVG BCR normal; textContent intact; only viewBox ratio check detects | All consent text (overflows SVG viewport at user x>1); SVG container appears normal size; DOM-present; textContent intact; button inside x=[0,1] range remains clickable | Critical |
| SA-CSS-VB-002: preserveAspectRatio="none" extreme aspect ratio | viewBox="0 0 1000 1" + preserveAspectRatio="none" + 400×300 SVG: scaleX=0.4, scaleY=300; text rendered as 0.008px wide × 6px tall sub-pixel stripes; completely unreadable; BCR normal; textContent intact | All consent text (sub-pixel width on narrow axis); each character a single-pixel-wide vertical stripe; human-illegible; getComputedStyle sees no color/opacity issues; viewBox aspect ratio check required | Critical |
| SA-CSS-VB-003: Nested inner SVG viewBox compression | Inner SVG width=400 height=300 viewBox="0 0 100000 100000": scale=0.004×; text at font-size=16 renders at 0.064px; outer+inner SVG BCRs both normal; only inner SVG viewBox ratio check detects; nested SVGs require recursive inspection | All consent text inside inner SVG (sub-pixel size); outer SVG appears normal; inner SVG BCR appears normal; only text rendered inside inner SVG is affected; button can be positioned at correct user coordinates to remain visible | High |
| SA-CSS-VB-004: viewBox window shift | viewBox="0 500 400 300" shifts visible window to Y=[500,800]; consent text at Y=10 is outside window, clipped; button at Y=600 inside window remains clickable; text BCR reports negative screen Y position; SVG container BCR normal | All consent text positioned at user Y<500 (scrolled above visible window); text BCR.top < svgBCR.top is detectable; button at Y=600 visible and clickable; user approves while consent text is above the visible SVG viewport | High |
Defences
- Check viewBox-to-viewport scaling ratio for all SVG elements wrapping consent — compute
scaleX = svgWidth / viewBoxWidthandscaleY = svgHeight / viewBoxHeight; flag any ratio >5 (extreme magnification causing overflow) or <0.1 (extreme compression causing sub-pixel rendering) as HIGH; flag >50 or <0.01 as CRITICAL. - Detect
preserveAspectRatio="none"with non-square viewBox — ifpreserveAspectRatio="none", computeviewBoxAspectRatio = viewBoxWidth / viewBoxHeight; flag any ratio >10 or <0.1 as CRITICAL, as the non-uniform scaling renders text illegible on the narrow axis. - Inspect all nested SVG elements recursively — an outer SVG may appear normal while an inner SVG contains the extreme viewBox; iterate
querySelectorAll('svg')(finds all SVG elements including nested) and check each one’s viewBox independently. - Verify text element BCRs fall within the SVG container BCR — for each
<text>element inside an SVG, computegetBoundingClientRect()and check that it intersects the SVG container’s BCR; any text element BCR entirely outside the SVG container is a CRITICAL finding (viewBox window shift). - Enforce a zero-SVG-wrapping policy for consent panels — consent panels should be rendered as plain HTML elements, not wrapped inside SVG elements; any SVG parent of a consent panel element is suspicious and requires viewBox inspection.