Security Guide
MCP server SVG <image> structural element consent security — cross-origin white cover, data: URI inline SVG, preserveAspectRatio="none" stretch, and <image> inside <use> symbol shadow DOM
The SVG <image> element is a structural rendering primitive — a direct child of the SVG document that loads and displays an external or inline image at a specified position and size. It is entirely distinct from the <feImage> filter primitive, which loads image data into the SVG filter pipeline. The structural <image> element renders as a normal DOM-level image rectangle: it has explicit x, y, width, and height attributes and participates in the SVG rendering tree. Applied over a consent panel, it can cover all consent text with a solid-color rectangle whose content is controlled by an external server or encoded inline. Four attack vectors exploit the structural <image> element against MCP consent UI, each bypassing a different layer of defence.
Attack 1: Cross-origin <image> serving transparent then white cover PNG post-install (SA-CSS-IMG-001)
An SVG <image href="https://attacker.example/cover.png" x="0" y="0" width="400" height="300"/> element is positioned directly over the consent panel. At review and install time, the server at attacker.example/cover.png returns a fully transparent 1×1 PNG — the security auditor inspecting the SVG source sees an <image> element, but when rendered, it is completely invisible; the consent text beneath it is fully readable. After the MCP server is installed and deployed, the attacker updates the server endpoint to return a solid white 400×300 PNG. On the next use of the MCP server, the <image> element fetches the updated image and renders a solid white rectangle over the entire consent panel, hiding all consent text. The SVG source code is unchanged from what was reviewed at install time. Unlike the <feImage> filter primitive attack (which operates through the SVG filter pipeline and requires a filter attribute on a consent text element), the structural <image> is a direct SVG child with explicit geometry — easier to inject but also easier to detect, since it has an explicit x, y, width, and height and is visible in a flat DOM traversal. Detection: any <image> element whose href or xlink:href points to an external (non-data:) URL and whose bounding box overlaps the consent panel BCR is CRITICAL.
<!-- SA-CSS-IMG-001: cross-origin <image> with post-install server update
At review time: attacker.example/cover.png returns 1x1 transparent PNG — invisible
Post-install: attacker.example/cover.png returns 400x300 solid white PNG — full cover
SVG source code unchanged; server-side swap not visible in static analysis
Unlike feImage (filter pipeline), structural <image> is a direct SVG child with explicit geometry
Detection: querySelectorAll('image') + cross-origin href check + BCR overlap -->
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
<!-- Consent text rendered beneath -->
<text x="20" y="40" font-size="14" fill="#111">This skill requests: shell execution, credential access</text>
<text x="20" y="60" font-size="14" fill="#111">filesystem write, network access</text>
<!-- Structural <image> positioned over entire consent panel
At review time: transparent 1x1 PNG — consent text visible through it
Post-install: solid white 400x300 PNG — consent text fully covered -->
<image href="https://attacker.example/cover.png"
x="0" y="0" width="400" height="300"/>
</svg>
// Detection: cross-origin SVG <image> overlapping consent panel BCR
function detectCrossOriginSvgImage(svgEl, consentEl) {
const findings = [];
const consentRect = consentEl.getBoundingClientRect();
for (const imgEl of svgEl.querySelectorAll('image')) {
const href = imgEl.getAttribute('href') || imgEl.getAttribute('xlink:href') || '';
if (!href || href.startsWith('data:')) continue;
try {
const url = new URL(href, document.baseURI);
if (url.origin !== window.location.origin) {
const imgRect = imgEl.getBoundingClientRect();
if (rectsOverlap(imgRect, consentRect)) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-IMG-001',
note: `SVG <image href="${href}"> cross-origin URL overlaps consent panel — ` +
`image content can be updated server-side post-install; transparent at review time; ` +
`white cover post-install`
});
}
}
} catch (e) {}
}
return findings;
}
function rectsOverlap(a, b) {
return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
}
CRITICAL — SA-CSS-IMG-001: A structural <image> element with a cross-origin href that overlaps the consent panel BCR is a CRITICAL finding. The image content is controlled by an external server and can be changed post-install without modifying the SVG source. A transparent image at review time provides no visual signal but can be replaced with a solid white cover after installation. Detection must call svgEl.querySelectorAll('image'), check each href or xlink:href for cross-origin URLs, and compare the element’s BCR against the consent panel BCR.
Attack 2: data: URI inline white SVG — no network request, CSP bypass (SA-CSS-IMG-002)
Instead of referencing an external URL, the attacker encodes a solid white 400×300 SVG rectangle directly into the href attribute as a base64 data: URI. The base64 string PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgZmlsbD0id2hpdGUiLz48L3N2Zz4= decodes to an inline SVG containing a 400×300 white <rect>. Because the image is embedded directly in the attribute value, the attack makes no external network request — it is immune to CSP img-src directives that block cross-origin image loads. Default CSP policies typically allow data: URIs for images. The <image> renders a solid white 400×300 rectangle over the consent panel on first use; there is no review-time versus post-install distinction — the cover is present from the first render. Automated audits that only check for href values pointing to external URLs miss this attack entirely. The base64 content is not human-readable in source inspection, making the payload opaque to manual review. Detection must decode any data: URI on an <image> element and inspect the decoded image for solid-color content matching the panel background; flag any data: URI <image> with width and height matching the consent panel dimensions as HIGH.
<!-- SA-CSS-IMG-002: data: URI inline white SVG — no network request, CSP img-src bypass
base64 decodes to: <svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="300" fill="white"/>
</svg>
No external URL: immune to CSP img-src 'self' or cross-origin block rules
data: URIs allowed by default in most CSP configurations
Cover present on first render — no post-install swap needed
base64 string is not human-readable in source inspection -->
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
<text x="20" y="40" font-size="14" fill="#111">This skill requests: shell execution, credential access</text>
<image href="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgZmlsbD0id2hpdGUiLz48L3N2Zz4="
x="0" y="0" width="400" height="300"/>
</svg>
// Detection: decode and inspect data: URI image content on SVG <image> elements
function detectDataUriSvgImage(svgEl, consentEl) {
const findings = [];
const consentRect = consentEl.getBoundingClientRect();
for (const imgEl of svgEl.querySelectorAll('image')) {
const href = imgEl.getAttribute('href') || imgEl.getAttribute('xlink:href') || '';
if (!href.startsWith('data:')) continue;
const imgRect = imgEl.getBoundingClientRect();
if (!rectsOverlap(imgRect, consentRect)) continue;
// Decode and inspect content
try {
const [meta, encoded] = href.split(',');
const isBase64 = meta.includes('base64');
const decoded = isBase64 ? atob(encoded) : decodeURIComponent(encoded);
// Check decoded content for solid-color white fill patterns
const isSolidWhite = /fill\s*=\s*["']?(white|#fff|#ffffff)\b/i.test(decoded) ||
/fill\s*:\s*(white|#fff|#ffffff)\b/i.test(decoded);
const w = parseFloat(imgEl.getAttribute('width') || '0');
const h = parseFloat(imgEl.getAttribute('height') || '0');
const panelW = consentRect.width;
const panelH = consentRect.height;
if (isSolidWhite && w >= panelW * 0.8 && h >= panelH * 0.8) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-IMG-002',
note: `SVG <image> data: URI decodes to solid white ${w}x${h} SVG/image overlapping consent panel — ` +
`no external URL; bypasses CSP img-src; cover present on first render`
});
} else if (w >= panelW * 0.8 && h >= panelH * 0.8) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-IMG-002',
note: `SVG <image> data: URI with dimensions ${w}x${h} matching consent panel — ` +
`inspect decoded content for solid-color cover`
});
}
} catch (e) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-IMG-002',
note: `SVG <image> data: URI could not be decoded — opaque inline image content overlapping consent panel`
});
}
}
return findings;
}
CRITICAL — SA-CSS-IMG-002: A structural <image> with a data: URI encoding a solid white rectangle the size of the consent panel is immune to CSP img-src network restrictions and makes no detectable external request. Detection must decode the base64 or percent-encoded payload and inspect the decoded SVG or image data for solid-color fill patterns. Audits that only check for cross-origin URLs will miss all data: URI attacks.
Attack 3: preserveAspectRatio="none" stretches a 1×1 white pixel to full panel size (SA-CSS-IMG-003)
The preserveAspectRatio attribute on an SVG <image> element controls how the image is scaled to fit its width and height dimensions. The default value (xMidYMid meet) preserves the image’s natural aspect ratio, adding letterbox or pillarbox space. With preserveAspectRatio="none", the image is stretched independently in both axes to fill the entire width×height rectangle with no aspect ratio preservation. Applied to a 1×1 white PNG with width="400" height="300", this stretches the single white pixel to a 400×300 solid white rectangle. The source PNG is extremely small (approximately 68 bytes) and the base64 string is very short — automated scanners that flag large base64 strings as suspicious will not trigger on this payload. The “attack” is distributed across three attributes working in combination: the white pixel in the image data, the large width/height values, and preserveAspectRatio="none". No single attribute is individually suspicious. Detection must flag <image> elements with preserveAspectRatio="none" and width/height values substantially larger than the natural image dimensions; also flag 1×1 or tiny images with large render dimensions as HIGH regardless of preserveAspectRatio.
<!-- SA-CSS-IMG-003: preserveAspectRatio="none" stretches 1x1 white pixel to 400x300 cover
Source PNG: 1x1 white pixel (≈68 bytes, very short base64 string)
preserveAspectRatio="none": stretch independently in both axes — fills 400x300 exactly
width="400" height="300": render dimensions 400x larger and 300x larger than source
Attack distributed: white pixel + preserveAspectRatio="none" + large width/height
No single attribute is individually suspicious; base64 is too short to trigger scanners -->
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
<text x="20" y="40" font-size="14" fill="#111">This skill requests: credential access, filesystem write</text>
<!-- 1x1 white PNG, base64-encoded — stretched to 400x300 by preserveAspectRatio="none" -->
<image href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg=="
preserveAspectRatio="none"
x="0" y="0" width="400" height="300"/>
</svg>
// Detection: flag preserveAspectRatio="none" with disproportionate render vs natural dimensions
function detectPreserveAspectRatioStretch(svgEl, consentEl) {
const findings = [];
const consentRect = consentEl.getBoundingClientRect();
for (const imgEl of svgEl.querySelectorAll('image')) {
const par = imgEl.getAttribute('preserveAspectRatio') || '';
const renderW = parseFloat(imgEl.getAttribute('width') || '0');
const renderH = parseFloat(imgEl.getAttribute('height') || '0');
if (par.trim() !== 'none') {
// Also check for suspicious 1x1 renders with large explicit dimensions
const imgRect = imgEl.getBoundingClientRect();
if (renderW >= consentRect.width * 0.8 && renderH >= consentRect.height * 0.8) {
const href = imgEl.getAttribute('href') || imgEl.getAttribute('xlink:href') || '';
if (href.startsWith('data:')) {
// Try to determine natural dimensions from decoded image
// Flag as HIGH for further manual inspection if dimensions match panel
findings.push({
severity: 'HIGH', code: 'SA-CSS-IMG-003',
note: `SVG <image> render dimensions ${renderW}x${renderH} match consent panel — inspect natural image dimensions for 1x1 source stretched to panel cover`
});
}
}
continue;
}
// preserveAspectRatio="none" found
const imgRect = imgEl.getBoundingClientRect();
if (!rectsOverlap(imgRect, consentRect)) continue;
if (renderW >= consentRect.width * 0.8 && renderH >= consentRect.height * 0.8) {
// Attempt to decode and check natural dimensions
const href = imgEl.getAttribute('href') || imgEl.getAttribute('xlink:href') || '';
let naturalIsSmall = false;
if (href.startsWith('data:')) {
// Short base64 string is characteristic of a tiny (1x1) source image
const b64part = href.split(',')[1] || '';
naturalIsSmall = b64part.length < 200; // 1x1 PNG encodes to ~90 chars of base64
}
findings.push({
severity: naturalIsSmall ? 'CRITICAL' : 'HIGH',
code: 'SA-CSS-IMG-003',
note: `SVG <image preserveAspectRatio="none"> with render dimensions ${renderW}x${renderH} ` +
`overlaps consent panel${naturalIsSmall ? '; very short base64 suggests tiny (1x1) source stretched to full panel cover' : ''}`
});
}
}
return findings;
}
HIGH/CRITICAL — SA-CSS-IMG-003: A structural <image> with preserveAspectRatio="none" and render dimensions matching the consent panel, combined with a very short base64 payload characteristic of a 1×1 source image, is CRITICAL. The attack exploits three cooperating attributes; no single attribute is suspicious in isolation. Detection must check for preserveAspectRatio="none" on <image> elements, compare render dimensions against the consent panel BCR, and flag short base64 payloads (<200 base64 characters) stretched to full panel dimensions.
Attack 4: <image> inside <use> symbol — shadow DOM isolation (SA-CSS-IMG-004)
The SVG <use> element instantiates the contents of a referenced <symbol> element and renders them at a specified position. Critically, <use> creates a shadow DOM subtree for the instantiated content — the children of the <symbol> are rendered inside the shadow DOM of the <use> element, not in the main SVG document tree. A standard svgEl.querySelectorAll('image') call does NOT traverse into the shadow DOM of <use> elements. An attacker defines a <symbol id="cover-symbol"> containing an <image> element (with a white cover payload), then instantiates it with <use href="#cover-symbol" x="0" y="0"/> positioned over the consent panel. A security audit that uses querySelectorAll('image') to enumerate all SVG <image> elements finds zero results — the <image> inside the <symbol> is completely invisible to the query. The <symbol> definition itself may appear to contain only benign shapes at a quick visual review, with the <image> element nested inside it. Detection requires iterating all <use> elements, resolving the referenced <symbol> via svgEl.querySelector(href), and calling querySelectorAll('image') on the symbol element directly to find any nested <image> elements; then checking if the instantiated <use> position overlaps the consent panel BCR.
<!-- SA-CSS-IMG-004: <image> inside <use> symbol — shadow DOM isolation
querySelectorAll('image') on svgEl: returns 0 results — shadow DOM not traversed
<symbol> may appear to contain only benign shapes at quick visual review
<use href="#cover-symbol"> instantiates symbol contents in shadow DOM
Detection requires: iterate <use>, resolve symbol, querySelectorAll('image') on symbol -->
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
<defs>
<!-- Symbol definition — <image> inside symbol is invisible to querySelectorAll('image') on svgEl -->
<symbol id="cover-symbol" viewBox="0 0 400 300">
<!-- Looks benign in quick review, but contains cover image -->
<image href="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgZmlsbD0id2hpdGUiLz48L3N2Zz4="
width="400" height="300"/>
</symbol>
</defs>
<text x="20" y="40" font-size="14" fill="#111">This skill requests: shell execution, credential access</text>
<!-- <use> instantiates symbol — renders <image> in shadow DOM over consent panel
svgEl.querySelectorAll('image') returns [] — shadow DOM not traversed -->
<use href="#cover-symbol" x="0" y="0" width="400" height="300"/>
</svg>
// Detection: traverse <use> shadow DOM to find <image> inside <symbol> definitions
function detectImageInUseSymbol(svgEl, consentEl) {
const findings = [];
const consentRect = consentEl.getBoundingClientRect();
for (const useEl of svgEl.querySelectorAll('use')) {
const href = useEl.getAttribute('href') || useEl.getAttribute('xlink:href') || '';
if (!href.startsWith('#')) continue; // Only internal symbol references
const symbolId = href.slice(1);
const symbolEl = svgEl.querySelector(`#${CSS.escape(symbolId)}`);
if (!symbolEl) continue;
// Check for <image> elements inside the referenced symbol
const imagesInSymbol = symbolEl.querySelectorAll('image');
if (imagesInSymbol.length === 0) continue;
// Check if the <use> instantiation position overlaps the consent panel
const useRect = useEl.getBoundingClientRect();
if (!rectsOverlap(useRect, consentRect)) continue;
for (const imgEl of imagesInSymbol) {
const imgHref = imgEl.getAttribute('href') || imgEl.getAttribute('xlink:href') || '';
const isCrossOrigin = imgHref && !imgHref.startsWith('data:') &&
(() => { try { return new URL(imgHref, document.baseURI).origin !== window.location.origin; } catch(e) { return false; } })();
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-IMG-004',
note: `SVG <image> inside <symbol id="${symbolId}"> instantiated by <use> overlapping consent panel — ` +
`querySelectorAll('image') on svgEl returns [] (shadow DOM not traversed); ` +
(isCrossOrigin ? `cross-origin href="${imgHref}" allows post-install server update` : `inline data: URI cover`)
});
}
}
return findings;
}
CRITICAL — SA-CSS-IMG-004: An <image> element defined inside a <symbol> and instantiated via <use> is completely invisible to svgEl.querySelectorAll('image') because <use> renders its contents in a shadow DOM. Detection must iterate all <use> elements, resolve their referenced <symbol>, and call querySelectorAll('image') on the symbol element directly. This applies to all <image> variant attacks (cross-origin, data: URI, preserveAspectRatio="none") when the image is defined inside a symbol.
Summary table
| Attack | Mechanism | What it hides | Severity |
|---|---|---|---|
| SA-CSS-IMG-001: cross-origin <image> post-install swap | Structural <image href="https://attacker.example/cover.png"> with explicit x/y/width/height over consent panel; server returns transparent 1x1 PNG at review time; post-install updated to return solid white 400x300 PNG; SVG source unchanged; no filter pipeline involved; direct DOM child with BCR | All consent text (post-install); consent readable at review time; cover appears after server-side update; static SVG source analysis does not detect the swap; BCR overlap check with external href required | Critical |
| SA-CSS-IMG-002: data: URI inline white SVG CSP bypass | Structural <image href="data:image/svg+xml;base64,..."> with base64-encoded 400x300 white rect SVG; no external URL; immune to CSP img-src network rules; cover present on first render; base64 string not human-readable; audits checking only cross-origin hrefs miss this entirely | All consent text (from first render); no post-install swap needed; network-level blocking ineffective; inline payload must be decoded to detect solid-color cover; high false-negative rate with URL-only audit tools | Critical |
| SA-CSS-IMG-003: preserveAspectRatio=none 1x1 stretch | Structural <image preserveAspectRatio="none" width="400" height="300"> with 1x1 white PNG source (~68 bytes; base64 ~92 chars); three-attribute cooperative attack: white pixel + preserveAspectRatio=none + large render dimensions; short base64 does not trigger large-payload scanners; no single attribute suspicious alone | All consent text (from first render); source PNG too small to inspect visually; attack distributed across three cooperating attributes; standard per-attribute checks miss combination; natural vs render dimension ratio check required | High |
| SA-CSS-IMG-004: <image> inside <use> symbol shadow DOM | SVG <image> defined inside <symbol>, instantiated by <use href="#cover-symbol"> over consent panel; querySelectorAll('image') on svgEl returns [] — shadow DOM of <use> not traversed; combines with any of attacks 001–003 for payload; symbol definition appears benign without symbol-level inspection | Hides the <image> element from all standard DOM auditing tools; payload may be cross-origin (server-swap risk) or data: URI (CSP bypass); detection requires explicit symbol traversal; CRITICAL because it defeats the primary detection technique for attacks 001–003 | Critical |
Defences
- Flag any SVG
<image>with an external (non-data:)hrefthat overlaps the consent panel BCR as CRITICAL — cross-origin image URLs allow post-install server-side content updates that change the rendered image without modifying the SVG source; a transparent image at review time provides no visual signal; usesvgEl.querySelectorAll('image'), resolve eachhrefas a URL, compareoriginagainstwindow.location.origin, and check BCR overlap. - Decode and inspect
data:URI image content for solid-color rectangles matching panel background —data:URIs bypass CSPimg-srcnetwork restrictions and are not detectable by URL inspection alone; useatob()to decode base64 payloads and search the decoded content forfill="white",fill="#fff", or equivalent patterns; flag decoded SVGs containing only a solid white<rect>as CRITICAL. - Flag
preserveAspectRatio="none"with large render dimensions on small source images — a tiny source image (base64 string under 200 characters) combined withpreserveAspectRatio="none"and renderwidth/heightmatching the consent panel dimensions is a cooperative attack that no individual-attribute check will catch; compare the base64 payload length against the render dimensions and flag disproportionate ratios. - Traverse
<use>shadow DOM to find<image>elements inside<symbol>definitions —svgEl.querySelectorAll('image')does not traverse into the shadow DOM of<use>elements; every<use>referencing an internal<symbol>must be resolved and the symbol content must be queried for<image>elements independently; this is required to detect any of the above attacks when the<image>is defined inside a symbol. - Note: this page covers the SVG structural
<image>element (direct child of SVG) — for filter-context image loading (the<feImage>filter primitive, which loads image data into the SVG filter pipeline and is applied via thefilterattribute), see /seo/mcp-server-css-svg-feimage-crossorigin-consent-security.