Security Guide

MCP server SVG <foreignObject> consent security — HTML-in-SVG layout bypass, overflow:visible expansion, nested coordinate transforms, and shadow DOM host isolation

SVG <foreignObject> embeds an HTML subtree inside an SVG element. The HTML content uses normal CSS layout (pixels, flexbox, block flow) while the foreignObject element’s x, y, width, and height attributes are expressed in SVG user units, which are subject to the ancestor SVG’s viewBox transform. This dual-coordinate-system property creates four exploitable attack vectors for MCP consent bypass: (1) placing the foreignObject’s HTML region outside the visible SVG viewport via viewBox scaling; (2) extending HTML content off-screen using overflow:visible; (3) stacking nested SVG viewBox transforms to compound the coordinate distortion; (4) hosting a shadow DOM element inside the foreignObject to block CSS traversal from the document root.

Attack 1: foreignObject HTML layout vs. SVG viewBox coordinate mismatch (SA-CSS-FOB-001)

A <foreignObject> element’s position and size (x, y, width, height) are in SVG user units. When the parent SVG has viewBox="0 0 1 1" and is 400×300px, every SVG user unit equals 400 viewport pixels horizontally and 300 viewport pixels vertically. A <foreignObject x="1.5" y="0" width="1" height="1"> starts at viewport x=600 — 200px past the right edge of the 400px-wide SVG — and is clipped by the SVG’s default overflow:hidden. The HTML consent UI inside the foreignObject is entirely off-screen. The consent div exists in the DOM with full textContent; its getBoundingClientRect reports coordinates past the right edge of the SVG element.

<!-- SA-CSS-FOB-001: foreignObject at user x=1.5 → viewport x=600 (clipped at 400px SVG width)
     viewBox="0 0 1 1" on 400×300 SVG: 1 user unit = 400px × 300px
     foreignObject width=1 height=1 → 400×300 viewport pixels — fills entire SVG
     but foreignObject x=1.5 starts at viewport x=600 — completely off-screen -->

<svg width="400" height="300" viewBox="0 0 1 1" style="overflow:hidden;position:fixed;top:50px;left:50px">
  <!-- A "legitimate" background image or pattern here at user x=0..1 (visible) -->
  <rect x="0" y="0" width="1" height="1" fill="#f8f8f8"/>
  <text x="0.1" y="0.5" font-size="0.05" fill="gray">SkillInstaller v2.1</text>

  <!-- Consent UI at x=1.5: outside viewBox [0,0,1,1] visible region [0..1, 0..1] -->
  <foreignObject x="1.5" y="0" width="1" height="1">
    <div xmlns="http://www.w3.org/1999/xhtml" style="font:14px/1.5 sans-serif;padding:20px">
      <p>This skill requests: shell command execution, filesystem write, credential access</p>
      <button onclick="grantPermission()">Allow</button>
    </div>
  </foreignObject>
</svg>

<!-- Detection:
  1. Find SVG with extreme viewBox ratio (svgW/viewBoxW > 100)
  2. For each foreignObject child, compute viewBox-mapped x: x_viewport = x_user * (svgW/viewBoxW)
  3. If x_viewport > svgBCR.right or y_viewport > svgBCR.bottom: CRITICAL -->

function detectForeignObjectPlacement(svgEl) {
  const viewBox = svgEl.getAttribute('viewBox');
  if (!viewBox) return [];
  const [minX, minY, vbW, vbH] = viewBox.split(/[\s,]+/).map(Number);
  const svgBCR = svgEl.getBoundingClientRect();
  const scaleX = svgBCR.width / vbW;
  const scaleY = svgBCR.height / vbH;
  const findings = [];

  for (const fo of svgEl.querySelectorAll('foreignObject')) {
    const foX = parseFloat(fo.getAttribute('x') || '0');
    const foY = parseFloat(fo.getAttribute('y') || '0');
    const vpX = (foX - minX) * scaleX;
    const vpY = (foY - minY) * scaleY;

    if (vpX > svgBCR.width || vpY > svgBCR.height || vpX < 0 || vpY < 0) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-FOB-001',
        note: `foreignObject at user (${foX},${foY}) → viewport (${vpX.toFixed(0)},${vpY.toFixed(0)}) outside SVG BCR (${svgBCR.width}×${svgBCR.height}) — HTML content off-screen due to viewBox scaling`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-FOB-001: A <foreignObject> placed at SVG user coordinates beyond the viewBox visible range renders its HTML content off-screen while the HTML DOM remains fully present. textContent on the foreignObject returns the consent text; getBoundingClientRect() on the consent div inside the foreignObject returns off-screen coordinates. Detection must compute the viewBox-to-viewport pixel transform for each foreignObject and check whether its mapped position falls outside the SVG element’s rendered bounds.

Attack 2: overflow:visible expanding consent HTML outside SVG viewport (SA-CSS-FOB-002)

By default, SVG elements clip their content to the SVG viewport (equivalent to overflow:hidden). When the SVG element has overflow:visible, content that extends beyond the viewBox visible region is rendered in the surrounding HTML document layout. A <foreignObject> positioned at a large negative y offset, combined with overflow:visible on the parent SVG, renders the HTML consent UI above the SVG element’s box — potentially behind other elements, in off-screen areas, or overlapping with content that obscures it. The attacker controls exactly where the foreignObject’s HTML subtree appears in the page without being constrained by the SVG element’s dimensions.

<!-- SA-CSS-FOB-002: overflow:visible + foreignObject at negative y
     SVG placed at viewport y=400; foreignObject at y=-300 (negative user units)
     With overflow:visible, the foreignObject renders at viewport y=100 — outside the SVG box,
     potentially behind a position:fixed cover element or in an off-screen region -->

<svg width="400" height="100" style="overflow:visible;position:absolute;top:400px;left:0">
  <!-- foreignObject at y=-300: positioned 300px ABOVE the SVG top edge
       SVG top is at page y=400; foreignObject renders at page y = 400 + (-300) = 100
       If there is a cover element at y=100 or the viewport does not scroll there: hidden -->
  <foreignObject x="0" y="-300" width="400" height="300">
    <div xmlns="http://www.w3.org/1999/xhtml" style="background:white;padding:20px">
      <p>Grants: shell, credentials, filesystem</p>
      <button>Allow</button>
    </div>
  </foreignObject>

  <!-- Visible installer UI at y=0 (inside the SVG box) -->
  <text x="20" y="50" font-size="14" fill="#333">Skill installed successfully.</text>
</svg>

// Detection: flag any SVG with overflow:visible that contains foreignObject elements
function detectOverflowVisibleFO(svgEl) {
  const overflow = getComputedStyle(svgEl).overflow;
  if (overflow === 'hidden') return [];

  const findings = [];
  for (const fo of svgEl.querySelectorAll('foreignObject')) {
    const foY = parseFloat(fo.getAttribute('y') || '0');
    if (foY < 0) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-FOB-002',
        note: `SVG overflow:visible + foreignObject y=${foY} (negative) — HTML renders ${Math.abs(foY)}px above SVG top edge; may appear in off-screen or obscured region`
      });
    } else {
      findings.push({
        severity: 'HIGH', code: 'SA-CSS-FOB-002',
        note: `SVG overflow:visible with foreignObject — HTML content may extend beyond SVG bounding box; check rendered position of foreignObject HTML children`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-FOB-002: overflow:visible on an SVG that contains a <foreignObject> with negative or large-offset coordinates places HTML content at arbitrary positions in the page layout, independent of the SVG element’s position. Detection must: (1) check SVG overflow style; (2) for any overflow:visible SVG, compute the absolute page position of all foreignObject elements and check whether those positions are outside the SVG element’s bounding box or in off-screen regions.

Attack 3: nested SVG › foreignObject › div — multiple coordinate transform stacking (SA-CSS-FOB-003)

Multiple nested SVG elements, each with their own viewBox transform, create a compounded coordinate system. A <foreignObject> inside the innermost SVG inherits the combined product of all ancestor viewBox scales. If each ancestor SVG applies a modest 10× scale, three levels of nesting yield a 1000× combined scale — placing the foreignObject at a position far outside any visible area. The outer SVG’s getBoundingClientRect() returns normal dimensions; each nested SVG’s BCR appears reasonable. Only recursive viewBox transform computation exposes the combined scale.

<!-- SA-CSS-FOB-003: nested SVG > SVG > foreignObject — stacked viewBox transforms
     Outer SVG: 400×300px, viewBox="0 0 40 30" (10× scale: 1 user unit = 10px)
     Inner SVG: 40×30 user units, viewBox="0 0 4 3" (10× inner scale: 1 inner unit = 10 outer units = 100px)
     Combined scale: 100× (1 inner unit = 100px)
     foreignObject at inner x=1 → outer user x=10 → viewport x=100 (visible)
     foreignObject at inner x=5 → outer user x=50 → viewport x=500 (outside 400px SVG)

  Only recursive viewBox computation reveals the actual viewport position -->

<svg width="400" height="300" viewBox="0 0 40 30">
  <svg x="0" y="0" width="40" height="30" viewBox="0 0 4 3">
    <foreignObject x="5" y="0" width="4" height="3">
      <!-- At 100× combined scale: x=5 outer units = 500px — off-screen -->
      <div xmlns="http://www.w3.org/1999/xhtml">
        <p>This grants shell command execution</p>
        <button>Allow</button>
      </div>
    </foreignObject>
  </svg>
</svg>

// Detection: recursive viewBox transform accumulation
function computeAccumulatedScale(el) {
  let scaleX = 1, scaleY = 1;
  let node = el;
  while (node && node !== document.documentElement) {
    if (node.tagName === 'svg' || node.tagName === 'SVG') {
      const vb = node.getAttribute('viewBox');
      if (vb) {
        const [,, vbW, vbH] = vb.split(/[\s,]+/).map(Number);
        const bcr = node.getBoundingClientRect();
        if (vbW && vbH) {
          scaleX *= bcr.width / vbW;
          scaleY *= bcr.height / vbH;
        }
      }
    }
    node = node.parentElement;
  }
  return { scaleX, scaleY };
}

HIGH — SA-CSS-FOB-003: Nested SVG elements compound their viewBox scales multiplicatively. A <foreignObject> in the innermost SVG may appear to have reasonable coordinates when only the immediate parent SVG’s scale is considered. Detection must accumulate the product of all ancestor SVG viewBox scales from the foreignObject’s position to the document root, then compute the absolute viewport-pixel position.

Attack 4: foreignObject containing a shadow DOM host element (SA-CSS-FOB-004)

An <foreignObject> can contain arbitrary HTML, including elements that host shadow DOM subtrees via element.attachShadow(). A consent panel rendered inside a shadow root within a foreignObject has two isolation barriers: (1) the SVG/HTML boundary between the foreignObject and the surrounding SVG; (2) the shadow root boundary isolating the consent HTML from document.querySelectorAll. CSS rules applied from the document root do not pierce the shadow boundary without ::part() selectors. The attack uses this double isolation to render a consent UI that is invisible to both SVG traversal and HTML DOM traversal from the light DOM.

<!-- SA-CSS-FOB-004: foreignObject containing a shadow DOM host
     The shadow root inside foreignObject is a separate DOM tree
     document.querySelectorAll('[data-consent]') finds nothing (in shadow DOM)
     getComputedStyle on shadow DOM elements fails from document root
     CSS variables from document scope do not pierce shadow root -->

// Attacker JS:
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;z-index:99999';
document.body.appendChild(svg);

const fo = document.createElementNS(svgNS, 'foreignObject');
fo.setAttribute('x', '0'); fo.setAttribute('y', '0');
fo.setAttribute('width', '100%'); fo.setAttribute('height', '100%');
svg.appendChild(fo);

const host = document.createElement('div');
fo.appendChild(host);

const shadow = host.attachShadow({ mode: 'closed' }); // 'closed' blocks host.shadowRoot access
shadow.innerHTML = `
  <style>
    /* CSS scoped to shadow root — document styles don't apply */
    :host { display:block; position:fixed; inset:0; background:white; z-index:99999; }
    p { font:14px/1.5 sans-serif; padding:20px; color:white; } /* white text on white bg */
    button { display:block; margin:20px auto; padding:10px 40px; }
  </style>
  <p>This permission grants shell execution and credential access.</p>
  <button onclick="this.getRootNode().host.dispatchEvent(new Event('allow',{bubbles:true}))">Allow</button>
`;
/* 'closed' shadow: host.shadowRoot === null; no external JS can traverse the shadow DOM */

// Detection:
function detectForeignObjectShadowHosts(svgEl) {
  const findings = [];
  for (const fo of svgEl.querySelectorAll('foreignObject')) {
    // Walk the foreignObject's light DOM for shadow hosts
    for (const el of fo.querySelectorAll('*')) {
      if (el.shadowRoot !== null) {
        // Open shadow — can inspect
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-FOB-004',
          note: `foreignObject contains open shadow DOM host — shadow subtree exists and can be inspected; check for consent UI inside shadow root`
        });
      } else {
        // Closed shadow — cannot inspect; flag for review
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-FOB-004',
          note: `foreignObject contains element with closed shadow root (shadowRoot===null) — consent UI may be inside closed shadow; cannot inspect shadow subtree from JS; flag for manual review`
        });
      }
    }
  }
  return findings;
}

CRITICAL — SA-CSS-FOB-004: A <foreignObject> containing a closed shadow DOM host creates a consent panel that is completely opaque to DOM-based audits. document.querySelectorAll finds nothing; getComputedStyle fails on shadow elements; element.shadowRoot returns null. Detection must: (1) enumerate all elements inside any <foreignObject> in the document; (2) for each element, check whether it has an attached shadow root; (3) flag open shadow roots as HIGH (inspectable but suspicious) and closed shadow roots as CRITICAL (opaque to inspection).

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-FOB-001: viewBox scale foreignObject off-screen foreignObject x/y in SVG user units scaled by viewBox; extreme viewBox ratio places HTML content outside SVG visible bounds; SVG overflow:hidden clips; HTML DOM present with full textContent; getBoundingClientRect returns off-screen coords All consent text (off-screen viewport-scaled HTML); button not clickable (outside SVG bounds); but JS event listeners may be attached to the button element Critical
SA-CSS-FOB-002: overflow:visible negative y off-screen SVG overflow:visible + foreignObject at negative y; HTML renders above SVG top edge at arbitrary page position; not clipped by SVG; may overlap with cover element or off-screen area Consent text rendered at unexpected page position outside SVG box; normal page scroll or fixed cover may hide it; BCR on SVG element normal, BCR on foreignObject children returns off-screen coords Critical
SA-CSS-FOB-003: nested SVG compound scale Multiple nested SVG viewBox transforms multiply; foreignObject in innermost SVG inherits compounded scale; outer BCR appears normal; only recursive scale product reveals actual viewport position Same as FOB-001 but hidden across multiple nesting levels; each individual scale appears modest; combined product is extreme; single-level viewBox check insufficient High
SA-CSS-FOB-004: shadow DOM host inside foreignObject HTML element inside foreignObject calls attachShadow({mode:'closed'}); consent UI in closed shadow; document.querySelectorAll finds nothing; element.shadowRoot===null; CSS from document does not pierce shadow Entire consent UI including text and button; shadow DOM isolates from all external JS traversal; closed mode blocks even internal inspection; double isolation barrier (SVG boundary + shadow boundary) Critical

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-FOB-001: foreignObject at SVG user coordinates beyond viewBox visible region — HTML content off-screen due to viewBox-to-viewport scaling; textContent present; viewBox transform computation required to detect.
CRITICAL SA-CSS-FOB-002: SVG overflow:visible with foreignObject at negative or large-offset y — HTML renders outside SVG bounding box at arbitrary page position; overflow:visible check + foreignObject coordinate audit required.
HIGH SA-CSS-FOB-003: nested SVG foreignObject with compounded viewBox scales — combined ancestor scale product places HTML outside visible bounds; single-level viewBox check insufficient; recursive scale computation required.
CRITICAL SA-CSS-FOB-004: foreignObject containing closed shadow DOM host — consent UI in closed shadow inaccessible to querySelectorAll and getComputedStyle; element.shadowRoot===null; manual review required for closed shadow DOM hosts in any foreignObject.

Related: SVG viewBox consent attacks  |  SVG use element consent attacks  |  SVG structural elements consent bypass synthesis

← Blog  |  Security Checklist