Security Guide
MCP server SVG display=none consent security — display=none on SVG text element removes it from layout and rendering while textContent remains accessible, parent <g> group hide evades attribute checks, selective display=inline override defeats any-visible-text checks, and mousedown injects display=none during the click window
The SVG display property is a first-class rendering control that, unlike visibility:hidden, removes the element from both layout and rendering entirely. An SVG text element with display=none has a bounding client rect of all zeros — no layout slot is reserved, no painting occurs, and the element cannot receive pointer events. Yet its textContent remains fully accessible via the DOM, and no fill, stroke, or opacity property is modified. A scanner checking fill color, stroke color, opacity, or font size finds all values normal; only the computed display property reveals the element has been removed from rendering. Four attacks exploit this surface: direct display=none on the text element itself, inheritance from a parent <g display="none"> group (so el.getAttribute('display') returns null on the text), selective display=inline override on a sibling header element that lets a visible-text check pass while the consent text inherits display=none, and JavaScript mousedown injection that sets display=none during the click window and removes it at mouseup. All four forms are defeated only by reading getComputedStyle(el).display on each consent text element individually.
Attack 1: display="none" presentation attribute on SVG <text> element (SA-CSS-DPN-001)
In HTML, display is exclusively a CSS property and cannot be set as an element attribute. In SVG, display is a presentation attribute — it can be set directly on the element as an attribute (display="none") and the SVG rendering engine treats it with the same effect as the CSS property. This dual-mode behavior means that a scanner exclusively checking CSS style properties via getComputedStyle will catch it (because SVG presentation attributes contribute to the computed style cascade), but a scanner that only reads element attributes for visible property checks could potentially miss the cascading implications if it relies on inline style checks only.
The key distinction from visibility:hidden is the effect on layout. An element with visibility:hidden is invisible but still occupies its layout slot — getBoundingClientRect returns non-zero dimensions, and the element can still receive pointer events in some implementations. An element with display:none is removed from the rendering tree entirely: getBoundingClientRect returns {x:0, y:0, width:0, height:0, top:0, right:0, bottom:0, left:0} — all zeros. This zero-BCR property is a reliable signal for display:none in the absence of computed-style access. However, it is not sufficient alone: an element that has never been laid out, or that is positioned off-screen at coordinate 0,0 with zero explicit dimensions, could also produce a zero BCR. The definitive check is getComputedStyle(el).display === 'none'. Secondary confirmation via BCR-all-zeros adds robustness. Detection must check the computed display property, not just el.getAttribute('display'), because presentation attributes are read through the cascade.
<!-- SA-CSS-DPN-001: display="none" presentation attribute on consent text element -->
<!-- Removed from layout and rendering; BCR returns all zeros; textContent intact -->
<!-- fill, stroke, opacity all normal; only computed display reveals the attack -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- display="none": presentation attribute; removes element from rendering tree -->
<!-- getComputedStyle(el).display = "none" -->
<!-- el.getBoundingClientRect() = {width:0, height:0, x:0, y:0, ...} -->
<!-- el.textContent = "Allow: shell execution, credential access" (intact!) -->
<text x="20" y="40" font-size="13" fill="#111"
display="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 getComputedStyle(el).display, not el.getAttribute('display')
function checkDisplayNone(svgEl) {
const findings = [];
for (const el of svgEl.querySelectorAll('text')) {
const cs = getComputedStyle(el);
// Primary check: computed display value
const displayVal = cs.display;
if (displayVal === 'none') {
const bcr = el.getBoundingClientRect();
const bcrAllZero = bcr.width === 0 && bcr.height === 0 &&
bcr.x === 0 && bcr.y === 0;
// Note: el.getAttribute('display') may return 'none' here (direct attribute case)
// but for parent-inherited cases it would return null — so attribute check alone is insufficient
const attrDisplay = el.getAttribute('display');
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-DPN-001',
note: `<${el.tagName}> getComputedStyle.display="none"; ` +
`el.getAttribute('display')="${attrDisplay}" ` +
`(attribute check finds "${attrDisplay}" — may be null if inherited); ` +
`BCR: width=${bcr.width} height=${bcr.height} x=${bcr.x} y=${bcr.y} ` +
`(all-zero=${bcrAllZero}); ` +
`element removed from layout and rendering; no pointer events; ` +
`fill="${cs.fill}" stroke="${cs.stroke}" opacity="${cs.opacity}" all normal; ` +
`textContent="${el.textContent.trim().slice(0,30)}..." intact (DOM accessible); ` +
`getComputedStyle.display check required; getAttribute('display') insufficient`
});
}
}
return findings;
}
CRITICAL — SA-CSS-DPN-001: display="none" as a presentation attribute on an SVG text element removes it from both layout and rendering. BCR returns all zeros — distinguishing it from visibility:hidden (which has non-zero BCR). textContent is intact; fill, stroke, and opacity are all normal. Detection requires getComputedStyle(el).display === 'none' or el.getAttribute('display') === 'none'; textContent presence or BCR alone cannot reliably confirm visibility.
Attack 2: display="none" on parent <g> group; inherited by consent text (SA-CSS-DPN-002)
CSS inheritance applies to the display property in SVG: a display:none on a parent group element propagates to all descendants. An SVG <g display="none"> element hides the group and all its children from rendering and layout. The child text element inherits this value through the CSS cascade, and its computed display becomes none even though no display attribute or style is set on the text element itself. This breaks the assumption that checking the text element’s own attributes is sufficient to assess its visibility.
The vulnerability is in the common pattern of checking only el.getAttribute('display') for SVG elements. When display is set on the text element as a presentation attribute, getAttribute('display') returns the value. When it is inherited from a parent, getAttribute('display') returns null on the text element — the attribute is not present on the element itself. A scanner checking el.getAttribute('display') === 'none' finds null !== 'none' and concludes the element is not hidden by display. The check passes despite the element being fully hidden. The fix is straightforward: always use getComputedStyle(el).display instead of or in addition to getAttribute. Computed style resolves the full CSS cascade including inherited values, pseudo-elements, and presentation attributes from ancestor elements.
<!-- SA-CSS-DPN-002: display="none" on parent <g>; text element has no display attr -->
<!-- el.getAttribute('display') returns null on text element — attribute check passes! -->
<!-- getComputedStyle(textEl).display = "none" (inherited via cascade) -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- parent group: display="none"; all children removed from rendering -->
<g display="none">
<!-- text element: no display attribute (getAttribute returns null) -->
<!-- getComputedStyle(el).display = "none" via cascade from parent group -->
<!-- el.getAttribute('display') = null — attribute check PASSES! -->
<text x="20" y="40" font-size="13" fill="#111">
Allow: shell execution, credential access
</text>
</g>
<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: getComputedStyle required; getAttribute misses parent-inherited display=none
function checkInheritedDisplayNone(svgEl) {
const findings = [];
for (const el of svgEl.querySelectorAll('text')) {
// getAttribute only reads the element's own attribute — misses inherited values
const attrDisplay = el.getAttribute('display');
const computedDisplay = getComputedStyle(el).display;
if (computedDisplay === 'none') {
const bcr = el.getBoundingClientRect();
// Determine whether the display=none came from the element itself or an ancestor
const source = attrDisplay === 'none' ? 'direct attribute'
: el.style.display === 'none' ? 'inline style'
: 'inherited from ancestor (getAttribute returns null — check PASSES!)';
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-DPN-002',
note: `<${el.tagName}> getComputedStyle.display="none"; ` +
`getAttribute('display')="${attrDisplay}" (${source}); ` +
`BCR: width=${bcr.width} height=${bcr.height} (all-zero); ` +
`display=none source: ${source}; ` +
`parent <g display="none"> propagates via CSS cascade; ` +
`el has no display attribute of its own: attribute check PASSES!; ` +
`getComputedStyle(el).display required (not getAttribute) for inherited detection; ` +
`textContent="${el.textContent.trim().slice(0,30)}..." intact`
});
}
}
return findings;
}
CRITICAL — SA-CSS-DPN-002: display="none" on a parent <g> group containing consent text. The text element itself has no display attribute: el.getAttribute('display') returns null (attribute check passes). getComputedStyle(textEl).display returns "none" due to CSS cascade inheritance from the parent group. Detection requires getComputedStyle(el).display, not just getAttribute('display').
Attack 3: selective display="none" with display="inline" override on header; scope text inherits hidden (SA-CSS-DPN-003)
The selective override attack uses the CSS cascade in a more targeted way: the parent <g> has display="none", making all children hidden by default. The header text element has an explicit display="inline" attribute, which overrides the inherited display:none from the parent group, making the header visible. The consent scope text element has no display attribute override, so it inherits display:none from the group and is invisible. A user looking at the dialog sees the header rendered normally and the consent scope as blank space.
This attack defeats two categories of naive checks. First, any-visible-text checks: a scanner that queries all text elements and checks whether any of them is visible concludes that the dialog has visible text (the header is visible) and passes. This check is meaningful for detecting complete SVG whiteouts but insufficient for per-element consent text protection. Second, the attribute-based display check described in DPN-002: the scope text element has no display attribute, so getAttribute('display') returns null. The two-level architecture — group-level hide with element-level selective show — requires that each consent text element be checked independently using getComputedStyle. The detection logic must verify getComputedStyle(consentEl).display !== 'none' for the specific consent scope element, not infer its visibility from the visibility of the header or any other element in the same parent group.
<!-- SA-CSS-DPN-003: parent g display=none; header text display=inline override (visible) -->
<!-- Consent scope text: no override; inherits display=none (invisible) -->
<!-- Any-visible-text check: finds header visible; PASSES. Per-element check required. -->
<svg width="440" height="140" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="140" fill="white"/>
<!-- parent group: display="none"; all children hidden by default -->
<g display="none">
<!-- header text: display="inline" overrides parent display=none; VISIBLE -->
<!-- any-visible-text check finds this element visible: PASSES -->
<text x="20" y="30" font-size="13" fill="#111"
display="inline">Permission Request:</text>
<!-- scope text: no display override; inherits display=none from group; INVISIBLE -->
<!-- getAttribute('display') = null; getComputedStyle.display = "none" -->
<text x="20" y="52" font-size="13" fill="#111">
Allow: shell execution, credential access
</text>
</g>
<rect x="20" y="90" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="110" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="90" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="110" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: per-element getComputedStyle check; any-visible-text aggregate check is insufficient
function checkSelectiveDisplayOverride(svgEl) {
const findings = [];
// Naive aggregate check — what the attack defeats
const allTexts = Array.from(svgEl.querySelectorAll('text'));
const anyVisible = allTexts.some(t => getComputedStyle(t).display !== 'none');
// anyVisible is true because the header has display=inline: naive check PASSES
// Correct check: each consent text element individually
for (const el of allTexts) {
const cs = getComputedStyle(el);
const computedDisplay = cs.display;
const attrDisplay = el.getAttribute('display');
if (computedDisplay === 'none') {
const bcr = el.getBoundingClientRect();
// Check if a sibling has display=inline (revealing the selective architecture)
const parent = el.parentElement;
const parentDisplay = parent ? getComputedStyle(parent).display : 'unknown';
const siblingsVisible = parent
? Array.from(parent.querySelectorAll('text')).some(s => s !== el && getComputedStyle(s).display !== 'none')
: false;
findings.push({
severity: 'HIGH', code: 'SA-CSS-DPN-003',
note: `<${el.tagName}> getComputedStyle.display="none"; ` +
`getAttribute('display')="${attrDisplay}" (null — attribute check passes); ` +
`parent display="${parentDisplay}" (parent group hidden); ` +
`sibling text visible=${siblingsVisible} (any-visible-text check passes!); ` +
`BCR: width=${bcr.width} height=${bcr.height} (all-zero); ` +
`selective architecture: parent group hidden; header text has display=inline override; ` +
`consent scope text inherits display=none without override; invisible; ` +
`per-element getComputedStyle.display check required for each consent text; ` +
`textContent="${el.textContent.trim().slice(0,30)}..." intact`
});
}
}
return findings;
}
HIGH — SA-CSS-DPN-003: Selective display="none" with display="inline" override. Parent <g display="none"> hides all children. Header text has display="inline" override (visible). Consent scope text has no override — inherits display=none (invisible). Any-visible-text check passes (header is visible); attribute check on consent scope returns null (passes). Per-element getComputedStyle(el).display check for each consent scope text element independently is required.
Attack 4: JS mousedown sets display="none" on consent text, removes at mouseup (SA-CSS-DPN-004)
The transient display-none injection attack is particularly effective because display=none has a unique side effect: it makes the element unreachable by pointer events. When display=none is set on the consent text element, the element has zero BCR and receives no pointer events. This creates a subtle detection problem: if the detection observer is attached to the text element itself, and the text element becomes display:none, the element is no longer a valid event target. However, the MutationObserver watching the DOM is unaffected by display:none — DOM mutations are structural changes that occur independently of the rendering state, so the observer still fires when the display attribute is modified even if the target element is currently hidden.
The attack flow: at rest, the consent text is visible (display:inline or no display attribute, inheriting the block/inline SVG default). The user reads the consent terms and initiates a click. At mousedown (capture phase, before any user-space handlers), JavaScript sets display="none" on the consent text element. The text element is removed from rendering: the text disappears for the duration of the press. At mouseup, the attribute is removed (or set to "inline") and the text reappears. The click itself is registered by the Allow or Deny button beneath, which received the click event. A MutationObserver watching the consent SVG container (with subtree: true) must be registered before interaction begins and must watch for display attribute changes and style attribute changes (CSS display property). Because the injection element may become pointer-event-unreachable, the monitor must observe the SVG container element, not the text element directly, to receive mutation events reliably throughout the hide/show cycle.
<!-- SA-CSS-DPN-004: consent text visible at rest; mousedown sets display=none; mouseup reverts -->
<!-- display=none makes element unreachable by pointer events; BCR returns zeros during click -->
<!-- Observer must watch SVG container, not text element (text may be unreachable after hide) -->
<svg id="consent-svg-dpn" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- at rest: display default (inline); text visible; BCR non-zero -->
<text id="scope-text-dpn" x="20" y="40" 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-dpn');
const text = document.getElementById('scope-text-dpn');
svg.addEventListener('mousedown', () => {
text.setAttribute('display', 'none');
// alternative CSS form: text.style.display = 'none';
}, true);
svg.addEventListener('mouseup', () => {
text.removeAttribute('display');
// alternative: text.style.display = '';
}, true);
})();
</script>
// Detection: MutationObserver on display attr and style; observe SVG container (subtree)
// Note: display=none makes element unreachable by pointer events — observe container, not text
function watchDisplayNoneInject(svgEl) {
const findings = [];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
const attr = mut.attributeName;
if (attr !== 'display' && attr !== 'style') continue;
const el = mut.target;
// Only text elements (not buttons, rects, etc.)
if (el.tagName !== 'text' && el.tagName !== 'tspan') continue;
let displayVal = null;
if (attr === 'display') {
displayVal = el.getAttribute('display');
} else {
// style attribute: check CSS display property
displayVal = el.style.display || null;
}
// Confirm the element is now display:none via computed style
// (getComputedStyle works even when element is display:none)
const computedDisplay = getComputedStyle(el).display;
if (computedDisplay !== 'none') continue;
const bcr = el.getBoundingClientRect();
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-DPN-004',
note: `Dynamic display="${displayVal}" on <${el.tagName}> during interaction; ` +
`getComputedStyle.display="none" confirmed; ` +
`element removed from layout and rendering; BCR: ${bcr.width}x${bcr.height} (zero); ` +
`element is unreachable by pointer events while display=none; ` +
`at rest: display default (visible); click: display=none; mouseup: reverts; ` +
`attribute changed via: ${attr}; ` +
`observer on SVG container (subtree=true) required — text element may be unreachable; ` +
`static scan passes (no display=none at rest); ` +
`textContent="${el.textContent?.trim().slice(0,30) ?? ''}..." intact`
});
}
});
// IMPORTANT: observe svgEl (container), not the text element directly
// The text element may become pointer-event-unreachable after display=none injection
observer.observe(svgEl, {
subtree: true, attributes: true,
attributeFilter: ['display', 'style']
});
svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
svgEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
return findings;
}
CRITICAL — SA-CSS-DPN-004: JavaScript sets display="none" at mousedown on consent text, removing it from layout and rendering during the click window. BCR returns zeros; element is unreachable by pointer events. At rest, text is visible and all static checks pass. MutationObserver must watch both the display attribute and the style attribute. The observer must be attached to the SVG container (not the text element), because display=none makes the text element unreachable — the container with subtree: true receives mutation events regardless of the target’s rendering state.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-DPN-001: display="none" presentation attribute on SVG text element | display=none as presentation attr; element removed from layout and rendering; BCR returns all zeros; textContent intact; fill/stroke/opacity all normal; only computed display reveals attack | textContent; BCR (returns zeros — distinguished from visibility:hidden); fill/stroke checks; getComputedStyle.display or getAttribute('display') check required | Critical |
| SA-CSS-DPN-002: display="none" on parent <g> group; inherited by text element | Parent <g display="none">; text has no display attr; getAttribute returns null (attribute check passes); getComputedStyle returns "none" via cascade | textContent; BCR (all-zero); getAttribute('display') check (returns null — passes!); getComputedStyle(el).display required for inherited detection | Critical |
| SA-CSS-DPN-003: selective display=none with display=inline on header; scope inherits hidden | Parent g display=none; header text display=inline override (visible); scope text no override (inherits display=none, invisible); any-visible-text check finds header: passes | textContent; any-visible-text check (header visible, passes); getAttribute on scope returns null; per-element getComputedStyle.display for each consent text required | High |
| SA-CSS-DPN-004: mousedown sets display=none; reverts mouseup | display default at rest (visible); mousedown sets display=none (removed from rendering; BCR zeros; pointer-unreachable); removed at mouseup; static scan passes | Static DOM; requires MutationObserver on display attr + style attr on SVG container (subtree=true); synthetic mousedown; note: text becomes pointer-unreachable while hidden | Critical |