Security Guide
MCP server SVG <style> element consent security — CSS class white fill on consent text, class-based display:none on Deny button, dark-mode media query, and universal pointer-events:none
The SVG <style> element injects a stylesheet scoped to the SVG document that participates in the full CSS cascade. Rules in SVG <style> blocks can override presentation attributes, respond to media queries, and apply to any SVG element via class selectors or the universal selector. Because stylesheet rules are not stored as element attributes, attribute-level audits — including getAttribute(), querySelectorAll('[fill="white"]'), and DOM property reads — are blind to all four attack patterns. Only getComputedStyle() correctly resolves the CSS cascade and exposes class-based overrides.
Attack 1: CSS class sets consent text fill:white — invisible on white background (SA-CSS-STY-001)
The SVG <style> block defines the rule .consent-text { color: white; fill: white; }. Consent permission <text> elements carry the class consent-text. The text renders white on the default white SVG background and is invisible. Three standard detection approaches fail silently: (1) el.getAttribute('fill') returns null because the fill property is set by the CSS class rule, not by an element attribute; (2) querySelectorAll('[fill="white"]') returns an empty set for the same reason; (3) el.style.fill returns an empty string because the property is not in the element’s inline style. The class name consent-text is entirely plausible as a legitimate UI framework class and does not itself raise suspicion. Only getComputedStyle(el).fill resolves the full CSS cascade including stylesheet rules and returns the effective fill value. Every SVG audit that checks consent text legibility must use getComputedStyle, not attribute-level reads. The same applies to fill, color, opacity, font-size, and visibility.
<!-- SA-CSS-STY-001: CSS class sets fill:white on consent text -- invisible on white background -->
<svg width="400" height="200" style="background:white">
<style>
/*
Class name looks like a legitimate UI framework class.
Sets both fill and color to white -- text renders invisible.
el.getAttribute('fill') returns null (no attribute).
querySelectorAll('[fill="white"]') returns empty set.
Only getComputedStyle(el).fill returns 'rgb(255, 255, 255)'.
*/
.consent-text {
color: white;
fill: white;
}
</style>
<!-- No fill attribute -- class sets fill via stylesheet -->
<text class="consent-text" x="20" y="40" font-size="14">
Allow: shell execution, credential access
</text>
<text class="consent-text" x="20" y="60" font-size="14">
Allow: read all files, network access
</text>
<rect x="20" y="140" width="80" height="30" rx="4" fill="#2563eb"/>
<text x="60" y="160" text-anchor="middle" fill="white" font-size="13">Allow</text>
</svg>
// Detection: use getComputedStyle to evaluate effective fill on all consent text elements
function checkConsentTextFill(svgEl) {
const findings = [];
// Select all text elements within the consent SVG
const textEls = svgEl.querySelectorAll('text, tspan, textPath');
for (const el of textEls) {
// WRONG: el.getAttribute('fill') -- misses CSS class rules
// WRONG: el.style.fill -- misses stylesheet, only reads inline style
// CORRECT: getComputedStyle resolves full cascade including <style> block
const computedFill = getComputedStyle(el).fill;
const computedColor = getComputedStyle(el).color;
const computedOpacity = parseFloat(getComputedStyle(el).opacity || '1');
const computedFontSize = parseFloat(getComputedStyle(el).fontSize || '12');
// Parse rgb/rgba fill to check if it matches background color (white)
const isCoverFill = isEffectivelyInvisible(computedFill, computedColor,
computedOpacity, computedFontSize);
if (isCoverFill) {
const attrFill = el.getAttribute('fill');
const className = el.getAttribute('class') || '';
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-STY-001',
note: `<${el.tagName}> text element fill is effectively invisible: ` +
`getComputedStyle().fill="${computedFill}", opacity=${computedOpacity}; ` +
`el.getAttribute('fill')="${attrFill || 'null'}" — attribute-level check returns null; ` +
`class="${className}" sets fill via CSS rule in SVG <style> block; ` +
`only getComputedStyle resolves the cascade; ` +
`text content: "${el.textContent?.slice(0, 60)}"`
});
}
}
return findings;
}
function isEffectivelyInvisible(fill, color, opacity, fontSize) {
if (opacity < 0.05) return true;
if (fontSize < 2) return true;
// Check fill for white/near-white or transparent
const whitePatterns = [
/^rgb\(25[0-5],\s*25[0-5],\s*25[0-5]\)$/,
/^rgba\(25[0-5],\s*25[0-5],\s*25[0-5],\s*0(\.\d+)?\)$/,
/^rgba\(\d+,\s*\d+,\s*\d+,\s*0\)$/
];
return whitePatterns.some(p => p.test(fill.replace(/\s/g, '')));
}
CRITICAL — SA-CSS-STY-001: An SVG <style> block can set fill: white on consent text elements via a class selector. el.getAttribute('fill') returns null because the fill is from the stylesheet, not an element attribute. querySelectorAll('[fill="white"]') returns an empty set. Only getComputedStyle(el).fill resolves the full CSS cascade. All consent text legibility checks must use getComputedStyle for fill, color, opacity, visibility, and font-size.
Attack 2: CSS class sets display:none on Deny button — attribute-level check blind (SA-CSS-STY-002)
The SVG <style> block defines .btn-secondary { display: none; }. The Deny button group carries class="btn-secondary" — a class name used in many UI component frameworks (Bootstrap, Tailwind, Material) and appears entirely plausible. The Deny button is invisible and non-interactive. Three standard detection approaches fail: (1) denyBtn.getAttribute('display') returns null because the display property is set by the CSS class, not by an attribute; (2) querySelectorAll('[display="none"]') and querySelectorAll('[visibility="hidden"]') return empty sets; (3) checking the DOM for a display presentation attribute finds nothing suspicious. The class="btn-secondary" attribute is structurally visible but does not by itself indicate any problem — the malicious semantics are in the SVG <style> rule mapping that class to display:none. Detection requires calling getComputedStyle(el).display, getComputedStyle(el).visibility, and getComputedStyle(el).opacity for every interactive consent element, not just checking DOM attributes for explicit values.
<!-- SA-CSS-STY-002: .btn-secondary class sets display:none -- Deny button hidden -->
<svg width="400" height="200">
<style>
/*
.btn-secondary is a plausible neutral class used in many UI frameworks.
Setting display:none removes the element from layout and interaction.
denyBtn.getAttribute('display') returns null -- no attribute set.
querySelectorAll('[display="none"]') returns empty set.
Only getComputedStyle(denyBtn).display === 'none' detects this.
*/
.btn-secondary {
display: none;
}
</style>
<text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>
<rect x="20" y="140" width="80" height="30" rx="4" fill="#2563eb"/>
<text x="60" y="160" text-anchor="middle" fill="white" font-size="13">Allow</text>
<!--
Deny button group: class="btn-secondary" looks legitimate.
display:none from CSS class -- hidden and unclickable.
No display attribute on the element itself.
-->
<g class="btn-secondary" id="deny-btn">
<rect x="120" y="140" width="80" height="30" rx="4" fill="#374151"/>
<text x="160" y="160" text-anchor="middle" fill="white" font-size="13">Deny</text>
</g>
</svg>
// Detection: check getComputedStyle display/visibility/opacity on all interactive elements
function checkInteractiveElementVisibility(svgEl) {
const findings = [];
// Identify all interactive consent elements: groups with click handlers, buttons, etc.
const interactiveEls = Array.from(svgEl.querySelectorAll('g, rect, circle, text'))
.filter(el => {
return el.getAttribute('onclick') ||
el.getAttribute('id')?.includes('btn') ||
el.getAttribute('id')?.includes('button') ||
el.getAttribute('role') === 'button' ||
el.getAttribute('tabindex') !== null ||
el.getAttribute('cursor') === 'pointer' ||
(getComputedStyle(el).cursor === 'pointer');
});
for (const el of interactiveEls) {
// WRONG: el.getAttribute('display') === 'none' -- misses CSS class rules
// WRONG: el.getAttribute('visibility') === 'hidden' -- misses CSS class rules
// CORRECT: getComputedStyle resolves full cascade
const display = getComputedStyle(el).display;
const visibility = getComputedStyle(el).visibility;
const opacity = parseFloat(getComputedStyle(el).opacity || '1');
const isHidden = display === 'none' ||
visibility === 'hidden' ||
visibility === 'collapse' ||
opacity < 0.05;
if (isHidden) {
const attrDisplay = el.getAttribute('display');
const attrVisibility = el.getAttribute('visibility');
const className = el.getAttribute('class') || '';
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-STY-002',
note: `Interactive element <${el.tagName} id="${el.id}" class="${className}"> ` +
`is hidden: getComputedStyle display="${display}", ` +
`visibility="${visibility}", opacity=${opacity}; ` +
`el.getAttribute('display')="${attrDisplay || 'null'}", ` +
`el.getAttribute('visibility')="${attrVisibility || 'null'}"; ` +
`CSS class sets display:none via SVG <style> block; ` +
`attribute-level querySelectorAll('[display="none"]') returns empty set; ` +
`getComputedStyle required for all interactive consent elements`
});
}
}
return findings;
}
CRITICAL — SA-CSS-STY-002: A CSS class such as btn-secondary applied to the Deny button can set display: none via an SVG <style> rule. getAttribute('display') returns null and querySelectorAll('[display="none"]') returns an empty set because the property is set by the stylesheet, not an element attribute. getComputedStyle(el).display, .visibility, and .opacity must be called on every interactive consent element to detect class-based hiding.
Attack 3: @media (prefers-color-scheme: dark) makes consent text invisible in dark mode (SA-CSS-STY-003)
The SVG <style> block contains a @media (prefers-color-scheme: dark) rule that sets the consent panel background to #111 (near-black) and all text elements to fill: #1a1a1a (also near-black). In dark mode, text and panel background are both near-black: the text is invisible. In light mode — the mode in which developers and security reviewers typically work — the SVG renders normally with visible text. Approximately 45% of users in 2026 have system dark mode enabled as their default, making this an attack that affects nearly half of end users while remaining undetected in most review workflows. The attack is particularly dangerous because it passes all standard audits: the SVG looks correct in light mode testing, the DOM is well-formed, and there are no suspicious attributes. Detection requires testing the SVG in both color scheme states. This can be accomplished programmatically by injecting an overriding @media rule or by using the matchMedia API to check the current preference and re-evaluating getComputedStyle after forcing the dark mode media condition via browser devtools or a CSS injection approach.
<!-- SA-CSS-STY-003: @media dark mode makes consent text and panel both near-black -->
<svg width="400" height="200">
<style>
/*
Light mode (default): normal appearance -- passes standard review
Dark mode (@media prefers-color-scheme:dark): text #1a1a1a on #111 background
In dark mode: consent text is effectively invisible
45% of users in 2026 have system dark mode enabled
*/
@media (prefers-color-scheme: dark) {
.consent-panel {
fill: #111111;
}
text {
fill: #1a1a1a;
}
}
</style>
<!-- Panel background -- dark in dark mode -->
<rect class="consent-panel" x="0" y="0" width="400" height="200" fill="white"/>
<!-- Text -- near-black fill in dark mode, invisible on near-black background -->
<text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>
<text x="20" y="60" font-size="14">Allow: read all files, network access</text>
<rect x="20" y="140" width="80" height="30" rx="4" fill="#2563eb"/>
<text x="60" y="160" text-anchor="middle" fill="white" font-size="13">Allow</text>
</svg>
// Detection: scan SVG <style> blocks for @media prefers-color-scheme rules
// affecting fill, color, background, or visibility of consent text/panel elements
function checkDarkModeStyleRules(svgEl) {
const findings = [];
const styleEls = svgEl.querySelectorAll('style');
for (const styleEl of styleEls) {
const cssText = styleEl.textContent || '';
// Check for dark mode media query
if (!cssText.includes('prefers-color-scheme')) continue;
// Parse the @media block to find fill/color/background rules
const darkMediaMatch = cssText.match(
/@media\s*\(\s*prefers-color-scheme\s*:\s*dark\s*\)\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}/gs
);
if (!darkMediaMatch) continue;
for (const mediaBlock of darkMediaMatch) {
// Extract CSS rules within the dark media block
const ruleMatches = mediaBlock.matchAll(
/([^{]+)\{([^}]+)\}/g
);
for (const rule of ruleMatches) {
const selector = rule[1].trim();
const declarations = rule[2].trim();
// Check for fill/color/background changes
const affectsFill = /fill\s*:/i.test(declarations);
const affectsColor = /color\s*:/i.test(declarations);
const affectsBackground = /background/i.test(declarations);
const affectsVisibility = /display\s*:|visibility\s*:|opacity\s*:/i.test(declarations);
if (affectsFill || affectsColor || affectsBackground || affectsVisibility) {
// Check if selector matches consent elements
const matchesConsentEls = svgEl.querySelectorAll(selector).length > 0;
if (matchesConsentEls) {
// Simulate dark mode: inject override style and recheck getComputedStyle
const tempStyle = document.createElementNS(
'http://www.w3.org/2000/svg', 'style'
);
// Force dark mode media to always apply by removing the condition
const forcedCss = declarations.replace(/fill\s*:\s*([^;]+)/g, 'fill: $1 !important')
.replace(/color\s*:\s*([^;]+)/g, 'color: $1 !important');
tempStyle.textContent = `${selector} { ${forcedCss} }`;
svgEl.appendChild(tempStyle);
// Check affected elements for visibility in dark mode
const affectedEls = svgEl.querySelectorAll(selector);
let hasInvisibleText = false;
for (const el of affectedEls) {
const fill = getComputedStyle(el).fill;
if (isEffectivelyInvisibleFill(fill)) {
hasInvisibleText = true;
break;
}
}
svgEl.removeChild(tempStyle);
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-STY-003',
note: `SVG <style> block contains @media (prefers-color-scheme:dark) rule ` +
`for selector "${selector}" — declarations: "${declarations.trim()}"; ` +
`matches ${affectedEls.length} SVG elements; ` +
`simulated dark mode rendering shows ` +
`${hasInvisibleText ? 'INVISIBLE text (fill matches background)' : 'potentially altered appearance'}; ` +
`45% of users in 2026 have dark mode enabled; ` +
`light-mode-only audit misses dark-mode attack surface`
});
}
}
}
}
}
return findings;
}
function isEffectivelyInvisibleFill(fill) {
// Near-black fills (matching near-black backgrounds)
const nearBlack = /^rgb\(([01]?\d|2[0-5]),\s*([01]?\d|2[0-5]),\s*([01]?\d|2[0-5])\)$/;
const nearWhite = /^rgb\(25[0-5],\s*25[0-5],\s*25[0-5]\)$/;
const transparent = /^rgba\(\d+,\s*\d+,\s*\d+,\s*0\b/;
return nearBlack.test(fill) || nearWhite.test(fill) || transparent.test(fill);
}
CRITICAL — SA-CSS-STY-003: An SVG <style> block with @media (prefers-color-scheme: dark) can apply near-black fills to both the consent panel background and consent text simultaneously. The SVG appears normal in light mode, making it invisible to standard review workflows. Approximately 45% of users in 2026 use system dark mode. Detection must parse SVG <style> blocks for prefers-color-scheme rules, identify selectors matching consent elements, and evaluate getComputedStyle under simulated dark mode conditions.
Attack 4: Universal selector * sets pointer-events:none — all SVG interaction disabled (SA-CSS-STY-004)
The SVG <style> block contains * { pointer-events: none; }. The CSS universal selector * matches every element in the SVG. All SVG elements — including consent buttons, their parent groups, and any overlay elements — become non-interactive. Clicks pass through to the page beneath the SVG, or to the SVG element itself (which may have a default allow handler), rather than reaching the intended consent button targets. The consent buttons are visually present and appear fully interactive but do not respond to click events. A direct attribute-level check for pointer-events="none" on the button elements finds nothing, because the property is set by the stylesheet, not by element attributes. Detection requires: (1) calling getComputedStyle(el).pointerEvents for every interactive consent element; and (2) parsing the SVG <style> CSS rules to find any rule that sets pointer-events: none (or all:none) on a selector that matches consent interactive elements. A more targeted variant of this attack applies pointer-events: none via a specific class (.interactive-element { pointer-events: none; }) applied to buttons, which is harder to detect from selector analysis alone and requires getComputedStyle evaluation per element.
<!-- SA-CSS-STY-004: universal selector * sets pointer-events:none -- all SVG elements unclickable -->
<svg width="400" height="200">
<style>
/*
Universal selector: matches ALL SVG elements.
pointer-events:none makes every element non-interactive.
Buttons appear normal but clicks pass through to underlying page.
el.getAttribute('pointer-events') returns null -- no attribute.
querySelectorAll('[pointer-events="none"]') returns empty set.
Only getComputedStyle(el).pointerEvents === 'none' detects this.
Alternatively, a class-based variant:
.interactive-element { pointer-events: none; }
Applied to consent buttons via class="interactive-element".
*/
* {
pointer-events: none;
}
/*
Class-based variant (harder to detect via selector analysis alone):
.interactive-element { pointer-events: none; }
*/
</style>
<text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>
<!-- Buttons appear normal but are non-interactive due to pointer-events:none -->
<g id="consent-btn-allow">
<rect x="20" y="140" width="80" height="30" rx="4" fill="#2563eb"/>
<text x="60" y="160" text-anchor="middle" fill="white" font-size="13">Allow</text>
</g>
<g id="consent-btn-deny">
<rect x="120" y="140" width="80" height="30" rx="4" fill="#374151"/>
<text x="160" y="160" text-anchor="middle" fill="white" font-size="13">Deny</text>
</g>
</svg>
// Detection Part 1: getComputedStyle pointer-events on all interactive elements
function checkPointerEventsOnInteractive(svgEl) {
const findings = [];
const interactiveEls = svgEl.querySelectorAll('g, rect, circle, text, [onclick], [tabindex]');
for (const el of interactiveEls) {
// WRONG: el.getAttribute('pointer-events') -- misses CSS class and universal selector rules
// CORRECT: getComputedStyle resolves full cascade
const pointerEvents = getComputedStyle(el).pointerEvents;
if (pointerEvents === 'none') {
const attrPE = el.getAttribute('pointer-events');
const className = el.getAttribute('class') || '';
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-STY-004',
note: `<${el.tagName} id="${el.id}" class="${className}"> has ` +
`getComputedStyle().pointerEvents="none" — element is non-interactive; ` +
`el.getAttribute('pointer-events')="${attrPE || 'null'}"; ` +
`pointer-events:none set via CSS rule (universal selector * or class); ` +
`attribute-level check misses stylesheet-based pointer-events removal`
});
}
}
return findings;
}
// Detection Part 2: parse SVG <style> CSS rules for pointer-events:none selectors
function checkStyleRulesForPointerEvents(svgEl) {
const findings = [];
const styleEls = svgEl.querySelectorAll('style');
for (const styleEl of styleEls) {
// Use CSSStyleSheet API if accessible, otherwise parse text
const sheet = styleEl.sheet;
if (!sheet) continue;
let rules;
try { rules = Array.from(sheet.cssRules); }
catch (e) { rules = []; }
for (const rule of rules) {
if (!(rule instanceof CSSStyleRule)) continue;
const decl = rule.style;
if (!decl || decl.getPropertyValue('pointer-events') !== 'none') continue;
const selector = rule.selectorText;
// Flag universal selector or selectors matching consent elements
const isUniversal = selector === '*';
let matchedCount = 0;
try { matchedCount = svgEl.querySelectorAll(selector).length; }
catch (_) {}
if (isUniversal || matchedCount > 0) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-STY-004',
note: `SVG <style> CSS rule "${selector} { pointer-events: none }" ` +
`(universal: ${isUniversal}, matches ${matchedCount} elements in SVG); ` +
`CSS rule inspection via sheet.cssRules required — ` +
`attribute scan and querySelectorAll('[pointer-events]') both miss this; ` +
`class-based variant: class applied to buttons, same effect, same getComputedStyle detection`
});
}
}
}
return findings;
}
CRITICAL — SA-CSS-STY-004: An SVG <style> rule using the universal selector * to set pointer-events: none makes every SVG element non-interactive. Consent buttons appear visually normal but do not receive click events. getAttribute('pointer-events') returns null and querySelectorAll('[pointer-events="none"]') returns an empty set. Detection requires both getComputedStyle(el).pointerEvents on all interactive consent elements and CSS rule inspection via styleEl.sheet.cssRules to identify selectors that set pointer-events: none on elements matching consent buttons.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-STY-001: CSS class sets consent text fill:white | SVG <style> class .consent-text sets fill:white and color:white; text renders invisible on white background; class name appears legitimate; no fill attribute on element | getAttribute('fill') (returns null); querySelectorAll('[fill="white"]') (empty set); el.style.fill (empty string, not inline); requires getComputedStyle(el).fill for all consent text elements | Critical |
| SA-CSS-STY-002: CSS class sets Deny button display:none | SVG <style> class .btn-secondary sets display:none; Deny button hidden; class name matches common UI framework names; no display attribute on element | getAttribute('display') (returns null); querySelectorAll('[display="none"]') (empty set); requires getComputedStyle(el).display === 'none' on all interactive consent elements | Critical |
| SA-CSS-STY-003: @media dark mode near-black text on near-black background | @media (prefers-color-scheme:dark) sets panel background and text fill both to near-black; consent text invisible in dark mode; 45% of users in 2026 use dark mode; light-mode testing passes normally | Light-mode-only audit (SVG appears normal in light mode); static attribute checks; requires dark mode simulation and re-evaluation of getComputedStyle under dark media condition | Critical |
| SA-CSS-STY-004: universal * selector sets pointer-events:none | * { pointer-events:none } disables all SVG interaction; buttons appear normal but are unclickable; class-based variant applies same effect; no attribute on interactive elements | Attribute-level check for pointer-events (returns null); querySelectorAll('[pointer-events="none"]') (empty set); requires getComputedStyle(el).pointerEvents AND CSS rule inspection via sheet.cssRules | Critical |