Security Guide
MCP server SVG paint server none consent security — fill="none" stroke="none" double-none ink suppression produces zero rendered pixels, textContent intact, parent BCR non-zero, and scanners checking opacity/visibility find nothing
SVG text elements use two independent paint mechanisms: fill (the interior ink of each glyph) and stroke (the outline drawn along each glyph contour). Setting either to none disables that paint server entirely. Setting both to none simultaneously causes the text element to produce zero rendered pixels on any conformant SVG renderer. The element remains in the DOM with its textContent fully intact, its getBoundingClientRect() returning a non-zero bounding box, and getComputedStyle returning fill: none; stroke: none — values that consent scanners checking for opacity: 0, visibility: hidden, display: none, or color: transparent will not flag. The attack is categorically distinct from fill-opacity: 0 (fill color present, opacity zeroed) and from opacity: 0 (element-level transparency). Four attack patterns: fill-only none, double-none, CSS paint-order interplay, and mousedown injection.
Attack 1: fill="none" stroke="none" — both paint servers disabled, zero glyph ink, consent text invisible (SA-CSS-PSN-001)
In the SVG paint model, every graphical element carries two independent paint servers: fill and stroke. The fill paints the closed interior of each glyph shape. The stroke paints along the outline path at a configurable width. The default values are fill="black" and stroke="none". When both are explicitly set to none, neither the glyph interiors nor the outlines produce any ink. The SVG renderer computes the text layout — glyph positions, advances, bounding boxes — and then skips the painting step entirely because both paint servers return “no paint.” The result is a fully laid-out but visually absent text element.
Scanners that test for CSS-style invisibility will find nothing suspicious. getComputedStyle(textEl).opacity returns 1. getComputedStyle(textEl).visibility returns visible. getComputedStyle(textEl).display returns inline. The element passes every standard visibility check. The BCR width and height are non-zero — the layout engine reserved space for the glyphs. The textContent property returns the consent string in full. Detection requires reading the SVG-specific fill and stroke attributes (or their computed equivalents via getComputedStyle with the fill property name, not the HTML color property) and checking that neither is none when both are present simultaneously.
<!-- SA-CSS-PSN-001: fill="none" stroke="none" on SVG text — both paint servers disabled -->
<!-- zero glyph ink: no fill interior, no stroke outline; text invisible -->
<!-- textContent = "Allow: shell execution, credential access" (intact) -->
<!-- getBoundingClientRect(): width > 0, height > 0 (layout computed, BCR non-zero) -->
<!-- getComputedStyle: opacity=1, visibility=visible, display=inline — no flags -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- fill="none" stroke="none": both paint servers → none; zero rendered pixels -->
<!-- layout is computed; BCR non-zero; textContent intact; CSS-scanner finds nothing -->
<text x="20" y="50" font-size="13" fill="none" stroke="none">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: check SVG fill and stroke attribute values directly
function checkPaintServerNone(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const text = textEl.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
// Read SVG-specific fill and stroke (not CSS color)
const fill = textEl.getAttribute('fill') ?? textEl.style.fill ?? '';
const stroke = textEl.getAttribute('stroke') ?? textEl.style.stroke ?? '';
const computedFill = getComputedStyle(textEl).fill ?? '';
const computedStroke = getComputedStyle(textEl).stroke ?? '';
const fillNone = fill === 'none' || computedFill.trim() === 'none';
const strokeNone = stroke === 'none' || computedStroke.trim() === 'none';
// If both paint servers are none, zero ink is produced
if (fillNone && strokeNone) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-PSN-001',
note: `SVG <${textEl.tagName}> fill="${fill}" stroke="${stroke}"; ` +
`both paint servers are "none"; zero rendered pixels; ` +
`textContent="${text.slice(0,40)}..." intact; BCR non-zero; ` +
`opacity=1, visibility=visible (CSS opacity scanner misses); ` +
`distinct from fill-opacity:0 (which keeps fill color, uses opacity)`
});
}
}
return findings;
}
CRITICAL — SA-CSS-PSN-001: fill="none" stroke="none" on an SVG <text> element disables both paint servers simultaneously. The glyph interiors have no fill ink; the glyph outlines have no stroke ink. The text is invisible but its textContent is intact, BCR is non-zero, and all CSS-level visibility properties (opacity, visibility, display) are at their defaults. Detection requires checking the SVG-specific fill and stroke attributes — or their computed values via getComputedStyle(el).fill — and flagging consent-bearing elements where both resolve to none.
Attack 2: fill="none" alone on SVG text — glyph interiors invisible; stroke-width=0 default leaves zero visible ink (SA-CSS-PSN-002)
The default SVG stroke width is 0 in most browsers (or the stroke is none by default). Setting only fill="none" disables the glyph interior fill. If the element has no explicit stroke attribute (inheriting the default of none or stroke-width: 0), the text is effectively invisible. This is subtler than the double-none case: the element has no stroke="none" attribute in the DOM, so a scanner checking specifically for both attributes being set finds only one. The fix for detection is to resolve the effective paint state — if fill is none and the stroke either is none or has zero width, the element produces zero ink.
The distinction between fill="none" and fill-opacity="0" matters for scanner design. With fill-opacity="0", the fill color is still present (e.g., fill="black" fill-opacity="0"); an attacker could detect this by checking getComputedStyle(el).fillOpacity. With fill="none", there is no fill color at all — the paint server reference itself is none. Scanners that check getComputedStyle(el).opacity or parse the fill attribute as a CSS color (expecting a color value) and test its alpha channel will fail to flag fill="none" because none does not parse as a color.
<!-- SA-CSS-PSN-002: fill="none" only — glyph interiors invisible; default stroke=none also → zero ink -->
<!-- no explicit stroke attr; inherited default stroke is none (width=0 on most browsers) -->
<!-- fill-opacity scanner finds nothing (no fill-opacity attr; fill attr = "none" not a color) -->
<!-- color:transparent scanner finds nothing (no CSS color set; SVG uses fill not CSS color) -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- fill="none": glyph interior has no paint; default stroke=none; zero visible ink -->
<!-- no fill-opacity attr; no CSS opacity; no visibility:hidden; CSS scanners pass -->
<text x="20" y="50" font-size="13" fill="none">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: effective paint state — fill "none" + stroke none/zero → zero ink
function checkEffectivePaintNone(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const text = textEl.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const cs = getComputedStyle(textEl);
const fillVal = textEl.getAttribute('fill') || cs.fill || '';
const strokeVal = textEl.getAttribute('stroke') || cs.stroke || '';
const strokeW = parseFloat(cs.strokeWidth ?? cs['stroke-width'] ?? '0') || 0;
const fillIsNone = fillVal.trim() === 'none';
const strokeIsNone = strokeVal.trim() === 'none' || strokeW === 0;
if (fillIsNone && strokeIsNone) {
const bcr = textEl.getBoundingClientRect();
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-PSN-002',
note: `SVG <${textEl.tagName}> fill="${fillVal}"; stroke="${strokeVal || 'unset'}"; ` +
`stroke-width=${strokeW}; effective paint = zero ink; ` +
`BCR w=${bcr.width.toFixed(0)}px h=${bcr.height.toFixed(0)}px (non-zero); ` +
`textContent="${text.slice(0,40)}..." intact; ` +
`fill-opacity and CSS opacity scanners both miss this (fill="none" is not a color); ` +
`detection: resolve effective fill paint server + effective stroke paint/width`
});
}
}
return findings;
}
CRITICAL — SA-CSS-PSN-002: fill="none" without an explicit stroke renders zero ink when the inherited stroke is also none (the default). A scanner that checks fill-opacity or parses the fill value as a CSS color and tests alpha will not flag this: fill="none" is not a color, it is a paint server reference that means “no paint.” Detection must resolve the effective paint state: if fill resolves to none and stroke resolves to none or stroke-width is zero, the element is invisible regardless of what opacity/visibility checks report.
Attack 3: CSS fill: none via <style> inside SVG overrides attribute, leaves fill attribute a plausible color (SA-CSS-PSN-003)
SVG supports inline <style> elements with CSS rules that target SVG elements. A CSS rule text.consent-text { fill: none; stroke: none; } inside the SVG overrides the presentational fill attribute, which may be set to a plausible value like fill="#111" to mislead static attribute-checking scanners. The computed fill, however, is none. Scanners that read only the DOM attribute fill (returning #111) will incorrectly conclude the text is visible. Detection must use getComputedStyle to read the resolved fill value after CSS cascade, not the raw DOM attribute.
This pattern is analogous to the CSS-attribute override pattern used with color in HTML: an element may have a visible attribute value but an invisible computed value after cascading. In SVG the relevant computed properties are fill (resolves the paint server reference, returning none if the paint server is disabled) and stroke. The fill-opacity property is separate and irrelevant here — it controls alpha of an existing fill color; when fill is none there is nothing for fill-opacity to modulate.
<!-- SA-CSS-PSN-003: CSS fill:none overrides presentational fill="#111" attribute -->
<!-- attribute fill="#111" — attribute-only scanner reports visible fill -->
<!-- CSS rule .consent-text{fill:none;stroke:none} — computed fill=none (overrides attr) -->
<!-- getComputedStyle(el).fill = "none"; attribute scanner sees fill="#111" (PASS = false negative) -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<style>
/* CSS fill:none overrides SVG presentation attr fill="#111" */
.consent-text { fill: none; stroke: none; }
</style>
<rect width="440" height="120" fill="white"/>
<!-- fill="#111" looks plausible to attribute scanner; CSS rule overrides to none -->
<!-- getComputedStyle(el).fill = "none" (CSS wins over presentational attr) -->
<text x="20" y="50" font-size="13" fill="#111" class="consent-text">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: ALWAYS use getComputedStyle, never raw attribute, for fill/stroke checks
function checkComputedFillNone(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
for (const textEl of svgEl.querySelectorAll('text, tspan')) {
const text = textEl.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
// Use getComputedStyle (post-cascade) not getAttribute (pre-cascade)
const cs = getComputedStyle(textEl);
const computedFill = cs.fill ?? textEl.getAttribute('fill') ?? '';
const computedStroke = cs.stroke ?? textEl.getAttribute('stroke') ?? '';
const attrFill = textEl.getAttribute('fill') ?? '';
const effectiveFillNone = computedFill.trim() === 'none';
const effectiveStrokeNone = computedStroke.trim() === 'none';
if (effectiveFillNone && effectiveStrokeNone && attrFill !== 'none') {
findings.push({
severity: 'HIGH', code: 'SA-CSS-PSN-003',
note: `CSS overrides fill: attr="${attrFill}" → computedFill="${computedFill}"; ` +
`computedStroke="${computedStroke}"; ` +
`attribute fill="${attrFill}" appears valid (non-none); ` +
`attribute-only scanner sees fill="${attrFill}" → PASS (false negative); ` +
`getComputedStyle resolves cascade correctly; ` +
`textContent="${text.slice(0,40)}..." intact; BCR non-zero; ` +
`class="${textEl.getAttribute('class')}" used as CSS selector in <style>`
});
}
}
return findings;
}
HIGH — SA-CSS-PSN-003: A <style> element inside the SVG applies fill: none; stroke: none to a class. The text element carries a plausible presentational attribute fill="#111". CSS specificity rules mean the CSS class rule overrides the presentational attribute. A scanner reading only DOM attributes sees fill="#111" and passes. Detection requires getComputedStyle(el).fill — the post-cascade value that reflects CSS rules — not the raw attribute.
Attack 4: JS mousedown sets fill="none" on consent text, reverts at mouseup (SA-CSS-PSN-004)
The dynamic pattern sets fill="none" (and optionally stroke="none") on the consent text element precisely during a mouse press. At rest the element has a visible fill (e.g., fill="#111"). A mousedown listener in the capture phase calls textEl.setAttribute(‘fill’, ‘none’) immediately before the click registers. The renderer repaints the text as invisible during the click window. At mouseup, the fill is restored and the text reappears. Static inspection finds only the legitimate fill="#111" attribute.
Detection requires a MutationObserver watching the fill and stroke attributes on consent-bearing text elements. The observer must be configured with attributes: true and attributeFilter: [‘fill’, ‘stroke’]. After each mutation, recompute the effective paint state and flag transitions to the double-none condition. A synthetic mousedown event should be dispatched on the SVG container to trigger the injection before the observer checks.
<!-- SA-CSS-PSN-004: at rest fill="#111" (visible); mousedown: fill="none" stroke="none" -->
<!-- text invisible during click window; mouseup: fill restored -->
<!-- static scan sees fill="#111"; MutationObserver required to catch dynamic mutation -->
<svg id="consent-svg-psn" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- at rest: fill="#111" (visible); mousedown → fill="none" stroke="none" → invisible -->
<text id="consent-text-psn" x="20" y="50" font-size="13" fill="#111">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
<script>
(function() {
const svg = document.getElementById('consent-svg-psn');
const text = document.getElementById('consent-text-psn');
svg.addEventListener('mousedown', () => {
text.setAttribute('fill', 'none'); // glyph interior: no ink
text.setAttribute('stroke', 'none'); // glyph outline: no ink
}, true); // capture phase — before bubble-phase click handlers
svg.addEventListener('mouseup', () => {
text.setAttribute('fill', '#111'); // restore
text.removeAttribute('stroke');
}, true);
})();
</script>
// Detection: MutationObserver watching fill/stroke attributes + synthetic mousedown
function watchFillNoneInject(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (!['fill', 'stroke'].includes(mut.attributeName)) continue;
const el = mut.target;
const text = el.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const fillVal = el.getAttribute('fill') ?? '';
const strokeVal = el.getAttribute('stroke') ?? '';
const fillNone = fillVal.trim() === 'none';
const strokeNone = strokeVal.trim() === 'none' || strokeVal === '';
if (fillNone) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-PSN-004',
note: `Dynamic setAttribute('${mut.attributeName}', '${el.getAttribute(mut.attributeName)}') ` +
`on consent text; fill="${fillVal}" stroke="${strokeVal}"; ` +
`effective paint = zero ink during click window; ` +
`at rest fill="${mut.oldValue}" (visible); static scan PASSES; ` +
`MutationObserver attributeFilter=['fill','stroke'] subtree=true required; ` +
`synthetic mousedown triggers injection; textContent="${text.slice(0,30)}..." intact`
});
}
}
});
// Observe all consent text elements
for (const el of svgEl.querySelectorAll('text, tspan')) {
observer.observe(el, { attributes: true, attributeFilter: ['fill', 'stroke'], attributeOldValue: true });
}
svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
svgEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
return findings;
}
CRITICAL — SA-CSS-PSN-004: JavaScript sets fill="none" and stroke="none" on the consent text at mousedown, making it invisible during the click window. At rest, fill="#111" is present and the text is visible; static scan passes. Detection requires a MutationObserver with attributeFilter: [‘fill’, ‘stroke’] on consent text elements, a synthetic mousedown dispatch to trigger the mutation, and an effective-paint-none check after each observed attribute change.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-PSN-001: fill="none" stroke="none" — double-none | Both SVG paint servers disabled; zero glyph interior ink and zero glyph outline ink; textContent intact; BCR non-zero; CSS visibility properties at defaults | opacity scanner; visibility scanner; display scanner; color:transparent scanner; fill-opacity scanner; requires: read fill attr/computed value and check for "none" | Critical |
| SA-CSS-PSN-002: fill="none" alone — default stroke also none → zero ink | fill="none" disables glyph interior; default stroke=none (SVG default); combined → zero ink; no explicit stroke attr in DOM (single-attr check misleads) | fill-opacity scanner; CSS color scanner; stroke-explicit scanner; requires: resolve effective paint state (fill + stroke + stroke-width) | Critical |
| SA-CSS-PSN-003: CSS fill:none overrides presentational fill="#111" attribute | SVG <style> applies fill:none;stroke:none via class selector; DOM attr fill="#111" looks valid; CSS cascade overrides; computed fill=none | Attribute-only scanner (sees fill="#111"); requires: getComputedStyle(el).fill post-cascade, not raw getAttribute("fill") | High |
| SA-CSS-PSN-004: mousedown injects fill="none"; reverts mouseup | At rest: fill="#111" (visible); mousedown (capture): fill="none" stroke="none"; click window = invisible; mouseup: fill restored; static scan passes | Static DOM; requires: MutationObserver attributeFilter=['fill','stroke'] + synthetic mousedown + effective-paint-none check post-mutation | Critical |