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

AttackMechanismWhat it hidesSeverity
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

SkillAudit findings for this attack surface

CRITICAL SA-CSS-VB-001: SVG viewBox extreme scaling on consent element wrapper — viewBox="0 0 1 1" with 400×300 viewport creates 400×/300× scaling; consent text outside [0,1] range is clipped by overflow:hidden; SVG BCR normal; textContent intact; viewBox-to-viewport ratio check required.
CRITICAL SA-CSS-VB-002: SVG preserveAspectRatio="none" extreme aspect ratio on consent wrapper — 1000:1 viewBox aspect ratio creates 0.4×/300× non-uniform scaling; text rendered as sub-pixel vertical stripes; completely unreadable; BCR normal; viewBox aspect ratio check under preserveAspectRatio="none" required.
HIGH SA-CSS-VB-003: Nested inner SVG viewBox compression on consent element — inner SVG scale=0.004×; consent text at font-size=16 renders at 0.064px; both outer/inner SVG BCRs appear normal; recursive SVG viewBox inspection across all nested SVG elements required.
HIGH SA-CSS-VB-004: SVG viewBox window shift on consent element wrapper — min-y=500 scrolls consent text (Y=10) out of visible viewport; button (Y=600) inside window and clickable; text BCR.top < svgBCR.top detectable; viewBox min-x/min-y magnitude check against viewBox dimensions required.

Related: SVG use element consent attacks  |  SVG gradient consent attacks  |  SVG filter chain consent attacks

← Blog  |  Security Checklist