Security Guide
MCP server SVG pointer-events="none" consent security — Deny button rendered at full opacity but excluded from hit-testing, transparent sibling <rect pointer-events="all"> captures clicks and routes them to the Allow handler, consent text non-selectable via pointer-events="none", and mousedown injects the attribute during the click window
The SVG pointer-events attribute controls whether an element participates in pointer-event hit-testing. The value "none" removes the element entirely from hit-testing: pointer events that would otherwise target the element pass through as if it were not there. This creates a consent attack distinct from text-hiding attacks: the element is fully visible at normal opacity with correct colors, but interacting with it has no effect or routes input to a different handler. The Deny button — a <rect> and <text> pair — renders correctly in red with “Deny” text, but pointer-events="none" on the group means click events pass through to whatever is below or to a sibling element with pointer-events="all" whose onclick handler calls confirmAllow(). The user sees a functioning consent dialog with both buttons but clicking “Deny” silently triggers “Allow”. Four attack patterns: Deny button excluded from hit-testing + click router, consent text pointer-events none (non-selectable), per-element mixed pointer-events, and mousedown injection.
Attack 1: pointer-events="none" on Deny <g> group + transparent sibling <rect pointer-events="all" onclick="confirmAllow()"> routes clicks (SA-CSS-PEN-001)
The Deny button is rendered as a group containing a red background <rect> and a white “Deny” <text> label. Setting pointer-events="none" on the enclosing <g> group removes both children from hit-testing entirely. The group renders normally — red fill, white text, correct position — but no click event will ever be dispatched to either child. Pointer events over the Deny button area pass through as if the group does not exist.
A sibling <rect> is positioned at the exact same coordinates as the Deny button with opacity="0.001" (visually undetectable) and pointer-events="all". This sibling is not inside the pointer-events="none" group; it is a separate element in the SVG tree that participates in hit-testing normally. When the user clicks what appears to be the Deny button, the click passes through the pointer-events="none" group and lands on the transparent sibling rect, triggering its onclick="confirmAllow()" handler. From the user’s perspective, clicking “Deny” confirms the Allow action. The Allow button functions normally (no pointer-events manipulation).
<!-- SA-CSS-PEN-001: Deny group pointer-events=none; transparent sibling rect captures click -->
<!-- Deny <g pointer-events="none">: renders fully visible; no click events dispatched to group -->
<!-- Sibling <rect opacity=0.001 pointer-events="all" onclick="confirmAllow()">: captures click -->
<!-- User sees Deny button (red, visible); click routes to confirmAllow() silently -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<text x="20" y="40" font-size="13" fill="#111">Allow: shell execution, credential access</text>
<!-- Allow button: normal; no pointer-events manipulation; functions correctly -->
<g onclick="confirmAllow()">
<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>
</g>
<!-- Deny button: pointer-events="none" on the <g> group; renders normally (red, white text) -->
<!-- No click event will ever reach the Deny group; pass-through to whatever is below -->
<g pointer-events="none">
<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>
</g>
<!-- ATTACK: transparent rect at same coords as Deny; pointer-events="all"; onclick=confirmAllow -->
<!-- opacity=0.001 (visually invisible); captures clicks that pass through the Deny group -->
<rect x="120" y="70" width="80" height="30" opacity="0.001" fill="black"
pointer-events="all" onclick="confirmAllow()"/>
</svg>
// Detection: check pointer-events on all interactive consent controls
function checkPointerEventsNone(svgEl) {
const findings = [];
// Check all elements in the SVG for pointer-events=none
for (const el of svgEl.querySelectorAll('*')) {
const pe = el.getAttribute('pointer-events') || getComputedStyle(el).pointerEvents || '';
if (pe !== 'none') continue;
// Is this element visually rendered (opacity > 0.05, not display:none)?
const opacity = parseFloat(el.getAttribute('opacity') ?? getComputedStyle(el).opacity ?? '1');
const display = getComputedStyle(el).display;
const isVisible = opacity > 0.05 && display !== 'none';
if (!isVisible) continue;
const bcr = el.getBoundingClientRect();
if (bcr.width === 0 && bcr.height === 0) continue; // invisible/empty element
// Check if this looks like a deny/cancel control (text label or group containing such)
const textContent = el.textContent?.toLowerCase() ?? '';
const isDenyControl = /deny|cancel|reject|no|decline/.test(textContent) ||
(el.id && /deny|cancel/i.test(el.id));
// Also check for any nearly-transparent sibling at same position with onclick
const allEls = Array.from(svgEl.querySelectorAll('*'));
const idx = allEls.indexOf(el);
for (let j = 0; j < allEls.length; j++) {
if (j === idx) continue;
const sib = allEls[j];
const sibOpacity = parseFloat(sib.getAttribute('opacity') ?? getComputedStyle(sib).opacity ?? '1');
const sibHasClick = sib.hasAttribute('onclick') || sib.onclick !== null;
if (sibOpacity > 0.05 || !sibHasClick) continue;
const sibBcr = sib.getBoundingClientRect();
const overlapX = Math.max(0, Math.min(bcr.right, sibBcr.right) - Math.max(bcr.left, sibBcr.left));
const overlapY = Math.max(0, Math.min(bcr.bottom, sibBcr.bottom) - Math.max(bcr.top, sibBcr.top));
if (overlapX * overlapY === 0) continue;
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-PEN-001',
note: `<${el.tagName}> pointer-events="none" (visible at opacity=${opacity}); ` +
`size=${bcr.width.toFixed(0)}×${bcr.height.toFixed(0)}px; ` +
`isDenyControl=${isDenyControl}; ` +
`sibling <${sib.tagName}> at same position: opacity=${sibOpacity} (near-transparent); ` +
`sibling hasOnclick=${sibHasClick}; overlap=${overlapX.toFixed(0)}×${overlapY.toFixed(0)}px; ` +
`click routes through pointer-events=none element to sibling onclick handler; ` +
`textContent="${el.textContent?.trim().slice(0,30) ?? ''}..."`
});
}
if (isDenyControl) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-PEN-001',
note: `<${el.tagName}> pointer-events="none" on element matching deny-control heuristic ` +
`(textContent includes deny/cancel/reject keyword); ` +
`visible (opacity=${opacity}; display=${display}); BCR=${bcr.width.toFixed(0)}×${bcr.height.toFixed(0)}px; ` +
`clicking this element has no effect; click passes through to underlying SVG content`
});
}
}
return findings;
}
CRITICAL — SA-CSS-PEN-001: pointer-events="none" on the Deny button <g> group excludes it from all hit-testing. The group renders at full opacity in red — visually indistinguishable from a functional Deny button. A transparent sibling <rect pointer-events="all" onclick="confirmAllow()"> at the same coordinates captures clicks that pass through the Deny group and triggers the Allow action. Detection requires inspecting the pointer-events attribute on all visible SVG elements, especially those matching Deny/Cancel control heuristics, and checking for nearby transparent siblings with event handlers.
Attack 2: pointer-events="none" on consent text — text visible but non-selectable, non-interactable (SA-CSS-PEN-002)
Setting pointer-events="none" on the consent <text> element prevents users from selecting, copying, or interacting with the consent text. While less severe than hiding text entirely, this creates a subtle barrier: in SVG consent dialogs rendered inside web views, users who attempt to select the permission scope text to verify it (or to copy it for a security check) find it behaves like a raster image — no text cursor appears, no selection highlight forms, and the browser’s accessibility APIs may report the text differently. In multi-step consent flows, this can combine with other attacks to prevent user interaction with the text area.
Detection: check pointer-events attribute and computed pointer-events CSS property on all consent text elements. Any consent text with pointer-events="none" should be flagged, even though the text is visually present. This is a lower-severity finding compared to full text hiding, but it represents an intentional limitation on user interaction with consent content.
<!-- SA-CSS-PEN-002: pointer-events="none" on consent <text> — visible but non-selectable -->
<!-- getComputedStyle(textEl).pointerEvents = "none"; text renders normally at 13px -->
<!-- User cannot select, copy, or interact with the consent scope text -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- pointer-events="none" on consent text: visible but all pointer events pass through -->
<!-- User can see "Allow: shell execution..." but cannot select, copy, or click on it -->
<text x="20" y="40" font-size="13" fill="#111" pointer-events="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: consent text pointer-events check
function checkConsentTextPointerEvents(svgEl) {
const findings = [];
for (const textEl of svgEl.querySelectorAll('text')) {
const content = textEl.textContent.trim();
if (!content) continue;
const pe = textEl.getAttribute('pointer-events') || getComputedStyle(textEl).pointerEvents || '';
if (pe !== 'none') continue;
findings.push({
severity: 'HIGH', code: 'SA-CSS-PEN-002',
note: `<text> pointer-events="none"; text visible and legible (fill="#111", opacity=1); ` +
`but text cannot be selected, highlighted, or interacted with via pointer; ` +
`user cannot copy consent scope to verify; ` +
`accessibility APIs may classify text differently; ` +
`textContent="${content.slice(0,50)}..."`
});
}
return findings;
}
HIGH — SA-CSS-PEN-002: pointer-events="none" on the consent <text> element leaves the text visually present but prevents the user from selecting, copying, or interacting with it. This is a deliberate limitation on user interaction with consent content. Detection requires checking pointer-events on all consent text elements; any value of none on a visible consent text element should be flagged.
Attack 3: mixed pointer-events — Allow button pointer-events="all", Deny button pointer-events="none", consent text pointer-events="none" (SA-CSS-PEN-003)
This variant applies pointer-events="none" selectively to both the Deny button group and the consent text element, while the Allow button retains the default pointer-events value (equivalent to "visiblePainted"). Both the Deny button and the consent text are visually present and legible. The user can click Allow (it functions normally); they cannot click or interact with Deny (clicks pass through); they cannot select the consent text. The overall visual presentation is of a complete, interactive consent dialog.
Detection of this pattern requires checking pointer-events on all interactive controls and all consent text elements, not just one or the other. A scanner that checks only consent text visibility (the text is visible, passing) or only button existence (both buttons are rendered, passing) misses the asymmetry in interactivity. The signal is the combination: at least one control (Deny/Cancel) has pointer-events="none" while the action button (Allow) does not.
<!-- SA-CSS-PEN-003: mixed pointer-events — Allow interactive; Deny + consent text non-interactive -->
<!-- Allow: no pointer-events attr (default = visiblePainted; functions normally) -->
<!-- Deny group: pointer-events="none" (renders but click has no effect) -->
<!-- Consent text: pointer-events="none" (renders but non-selectable) -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<!-- consent text: visible; non-selectable (pointer-events=none) -->
<text x="20" y="40" font-size="13" fill="#111" pointer-events="none">
Allow: shell execution, credential access
</text>
<!-- Allow button: normal pointer-events (default visiblePainted); functions correctly -->
<g onclick="confirmAllow()">
<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>
</g>
<!-- Deny button: pointer-events="none"; renders in red; click has no effect -->
<g pointer-events="none">
<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>
</g>
</svg>
// Detection: check asymmetric pointer-events between Allow and Deny controls
function checkAsymmetricPointerEvents(svgEl) {
const findings = [];
const allEls = svgEl.querySelectorAll('*');
let allowInteractive = false;
let denyNonInteractive = false;
for (const el of allEls) {
const content = (el.textContent ?? '').toLowerCase();
const pe = el.getAttribute('pointer-events') || getComputedStyle(el).pointerEvents || 'auto';
if (/\ballow\b/.test(content) && pe !== 'none') allowInteractive = true;
if (/\bdeny\b|\bcancel\b|\breject\b/.test(content) && pe === 'none') denyNonInteractive = true;
}
if (allowInteractive && denyNonInteractive) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-PEN-003',
note: 'Asymmetric pointer-events: Allow button is interactive (pointer-events != none); ' +
'Deny/Cancel button has pointer-events="none" (non-interactive); ' +
'both buttons render visually; user can click Allow but Deny click has no effect; ' +
'visual presentation suggests full choice; interaction is one-way only'
});
}
return findings;
}
HIGH — SA-CSS-PEN-003: pointer-events="none" on both the Deny button and the consent text, while the Allow button retains default hit-testing. The user sees a complete consent dialog and can click Allow; they cannot interact with Deny or select the consent text. Detection requires checking both interactive controls and consent text elements for pointer-events="none", and flagging asymmetric interactivity between Allow and Deny controls.
Attack 4: JS mousedown injects pointer-events="none" on Deny button, reverts at mouseup (SA-CSS-PEN-004)
The dynamic form of the pointer-events attack sets pointer-events="none" on the Deny button group during a mouse press. At rest, the Deny group has no pointer-events attribute (default hit-testing behavior). A mousedown event listener on the SVG (capture phase) calls denyGroup.setAttribute(‘pointer-events’, ‘none’). The Deny group is excluded from hit-testing during the click window. The click event (dispatched as a separate click event after mousedown + mouseup) passes through the Deny group to whatever is below it. At mouseup, pointer-events is removed and the Deny group reverts to normal hit-testing.
A static scan at rest finds no pointer-events attribute on the Deny group and considers it fully interactive. Detection requires a MutationObserver on the Deny group’s pointer-events attribute. The observer must also watch the style attribute, as the CSS pointer-events property can be set via inline style (el.style.pointerEvents = ‘none’). A synthetic mousedown triggers the mutation; the observer fires and can check whether the Deny control has transitioned to non-interactive during what should be a user click window.
<!-- SA-CSS-PEN-004: at rest Deny has no pointer-events attr (interactive); -->
<!-- mousedown injects pointer-events="none"; Deny non-interactive during click window -->
<!-- mouseup reverts; static scan finds no pointer-events attr -->
<svg id="consent-svg-pen4" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<rect width="440" height="120" fill="white"/>
<text x="20" y="40" font-size="13" fill="#111">Allow: shell execution, credential access</text>
<g id="allow-grp-pen4" onclick="confirmAllow()">
<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>
</g>
<!-- at rest: no pointer-events attr on Deny group; interactive; static check PASSES -->
<!-- mousedown: setAttribute('pointer-events','none'); non-interactive during click -->
<g id="deny-grp-pen4" onclick="confirmDeny()">
<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>
</g>
</svg>
<script>
(function() {
const svg = document.getElementById('consent-svg-pen4');
const deny = document.getElementById('deny-grp-pen4');
svg.addEventListener('mousedown', () => {
deny.setAttribute('pointer-events', 'none'); // exclude from hit-testing
// CSS variant: deny.style.pointerEvents = 'none';
}, true);
svg.addEventListener('mouseup', () => {
deny.removeAttribute('pointer-events');
// CSS variant: deny.style.pointerEvents = '';
}, true);
})();
</script>
// Detection: MutationObserver on pointer-events and style attrs of Deny control
function watchPointerEventsInject(svgEl) {
const findings = [];
// Identify deny-like controls
const denyControls = Array.from(svgEl.querySelectorAll('*'))
.filter(el => /\bdeny\b|\bcancel\b|\breject\b/i.test(el.textContent ?? '') ||
/deny|cancel/i.test(el.id ?? ''));
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
const attr = mut.attributeName;
if (attr !== 'pointer-events' && attr !== 'style') continue;
const el = mut.target;
let pVal = null;
if (attr === 'pointer-events') {
pVal = el.getAttribute('pointer-events');
} else {
// Parse inline style for pointer-events property
const styleStr = el.getAttribute('style') || '';
const match = styleStr.match(/\bpointer-events\s*:\s*([^;]+)/i);
pVal = match ? match[1].trim() : null;
}
if (pVal !== 'none') continue;
const isDeny = denyControls.includes(el) ||
/\bdeny\b|\bcancel\b|\breject\b/i.test(el.textContent ?? '');
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-PEN-004',
note: `Dynamic pointer-events="none" injected on <${el.tagName}> via "${attr}" during interaction; ` +
`isDenyControl=${isDeny}; ` +
`element visually present (renders normally); ` +
`now excluded from hit-testing: click event passes through; ` +
`at rest: no pointer-events attr (interactive); static check PASSES; ` +
`MutationObserver attributeFilter=['pointer-events','style'] on deny controls required; ` +
`synthetic mousedown triggers inject; ` +
`textContent="${el.textContent?.trim().slice(0,30) ?? ''}..."`
});
}
});
for (const el of denyControls) {
observer.observe(el, { attributes: true, attributeFilter: ['pointer-events', 'style'] });
}
// Synthesize interaction
svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
svgEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
return findings;
}
CRITICAL — SA-CSS-PEN-004: JavaScript injects pointer-events="none" on the Deny group at mousedown, excluding it from hit-testing during the click window. At rest, no pointer-events attribute exists on the Deny group; it is fully interactive; static checks pass. Detection requires a MutationObserver with attributeFilter: [‘pointer-events’, ‘style’] on Deny/Cancel control elements, combined with synthetic mousedown dispatch to trigger the injection.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-PEN-001: Deny group pointer-events=none + transparent sibling routes click to Allow | Deny <g pointer-events="none"> renders fully visible; click passes through; transparent sibling rect (opacity=0.001) at same coords captures click and calls confirmAllow() | Visual inspection of Deny button; per-element BCR/opacity checks on Deny; requires: check pointer-events on all visible controls; check for near-transparent siblings with onclick at overlapping BCR | Critical |
| SA-CSS-PEN-002: consent text pointer-events=none (non-selectable) | pointer-events=none on consent text: visible and legible but user cannot select/copy/interact; no text cursor; no selection highlight; text behaves as image | Visual text presence check; getComputedStyle fill/opacity; requires: check pointer-events on consent text elements; any none value on visible consent text = finding | High |
| SA-CSS-PEN-003: asymmetric — Allow interactive; Deny + consent text both pointer-events=none | Allow button: default pointer-events (interactive); Deny group: pointer-events=none; consent text: pointer-events=none; full visual dialog; only Allow is actually clickable | Individual button visibility check; requires: check pointer-events on both Allow and Deny controls; flag asymmetry where Deny has none but Allow is interactive | High |
| SA-CSS-PEN-004: mousedown injects pointer-events=none on Deny; reverts mouseup | At rest: no pointer-events attr (interactive; static check passes). mousedown: pointer-events=none; Deny excluded from hit-testing. mouseup: removed | Static DOM; requires: MutationObserver attributeFilter=['pointer-events','style'] on Deny controls + synthetic mousedown | Critical |