Security Guide

MCP server SVG pointer-events consent security — pointer-events="none" on consent button, visibleFill with fill="none", transparent overlay click capture, and JS mousedown injection

The SVG pointer-events attribute controls whether and under what geometric conditions an SVG element responds to mouse and touch input. Unlike the CSS pointer-events property (which has only auto and none), the SVG attribute defines a full set of geometry-dependent values — visibleFill, visibleStroke, visiblePainted, fill, stroke, painted, and all — each tied to the element’s fill and stroke rendering model. Applied to consent button elements, these values can silently disable click reception through combinations that are individually innocuous and not flagged by opacity or visibility audits. Four attack vectors exploit the SVG pointer-events attribute against MCP consent UI.

Attack 1: pointer-events="none" on consent button SVG element (SA-CSS-PEV-001)

An SVG <rect> or <g> element comprising the consent button has pointer-events="none" applied directly. The button is fully visible — it has a normal fill, stroke, and opacity — but it does not receive any pointer events. Clicks pass through the button element to whatever lies beneath it in the SVG hit-test stack. What lies beneath determines the observable effect: either empty SVG space (the click does nothing; the user sees the button but nothing happens), or a different SVG element positioned at the same location that performs a different action (for example, a <rect> wired to a “cancel” event handler instead of “confirm”). A quick audit checking opacity, visibility, or display would not flag this element; only an explicit check for pointer-events detects it. This is distinct from CSS pointer-events: none on an HTML element (which is well-known and commonly audited) — the SVG attribute version is encountered less frequently in security tooling and may be overlooked in HTML-focused audits that do not descend into SVG subtrees.

<!-- SA-CSS-PEV-001: pointer-events="none" on consent button SVG element
     Button is fully visible (fill, stroke, opacity all normal)
     pointer-events="none": no mouse or touch events reach this element
     Clicks pass through to whatever is beneath in SVG z-order
     getComputedStyle(buttonEl).pointerEvents returns "none"
     Opacity/visibility audit finds nothing suspicious -->

<svg width="320" height="80">
  <!-- Hidden cancel handler beneath the consent button -->
  <rect x="10" y="10" width="140" height="44" rx="6"
        fill="#e5e7eb" style="cursor:pointer"
        id="cancel-trap"/>

  <!-- Consent button: visible, styled correctly, but unclickable -->
  <g id="consent-button" pointer-events="none">
    <rect x="10" y="10" width="140" height="44" rx="6"
          fill="#2563eb" stroke="none"/>
    <text x="80" y="37" text-anchor="middle"
          fill="#fff" font-size="14">Allow</text>
  </g>
</svg>

<script>
// Cancel trap receives all clicks because consent button has pointer-events="none"
document.getElementById('cancel-trap').addEventListener('click', () => {
  // Silently decline consent — user sees "Allow" button, action is "Cancel"
  denyConsent();
});
</script>

// Detection: check getAttribute and getComputedStyle on consent button and ancestors
function detectPointerEventsNone(consentEl) {
  const findings = [];
  const check = (el) => {
    const attr = el.getAttribute('pointer-events');
    const computed = getComputedStyle(el).pointerEvents;
    if (attr === 'none' || computed === 'none') {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-PEV-001',
        note: `Element <${el.tagName}> has pointer-events="none" — element is visible but cannot receive clicks; consent button or container unclickable despite visual presence`
      });
    }
    for (const child of el.children) check(child);
  };
  check(consentEl);
  return findings;
}

CRITICAL — SA-CSS-PEV-001: pointer-events="none" on a consent button SVG element makes the button visually normal but completely unresponsive to clicks. A static check of opacity, visibility, or display finds no anomaly. Detection must call both getAttribute('pointer-events') and getComputedStyle(el).pointerEvents on the consent button element and every element in its ancestor chain, and flag "none" on any element in the click path as CRITICAL.

Attack 2: pointer-events="visibleFill" on consent element with fill="none" (SA-CSS-PEV-002)

SVG defines several pointer-events keyword values beyond the CSS "none"/"auto" pair. The value visibleFill means: “only receive pointer events if the element is visible AND the pointer is over the element’s fill area.” If the element has fill="none", there is no fill area; no pointer is ever “over the fill area” because the fill area does not exist. Pointer events never fire even though pointer-events is not set to "none". Applied to a consent button <rect fill="none" pointer-events="visibleFill">, the button renders with only a stroke border (a hollow or outline-style button), which is a common and legitimate UI pattern. Neither the pointer-events value ("visibleFill" — not "none") nor the fill value ("none" — outline buttons are normal) individually raises a flag; only their combination disables click reception. The same logic applies to visibleStroke when stroke="none", and to visiblePainted when both fill and stroke are "none".

<!-- SA-CSS-PEV-002: pointer-events="visibleFill" on fill="none" element
     visibleFill: events fire only if element visible AND pointer over fill area
     fill="none": no fill area exists — visibleFill condition never satisfied
     Button renders as outline rect (common UI pattern — not suspicious alone)
     pointer-events is not "none" so naive pointer-events check passes
     fill="none" alone not suspicious (outline button is legitimate) -->

<svg width="320" height="80">
  <!-- Consent button: outline style, pointer-events="visibleFill" + fill="none"
       Renders correctly but receives no click events -->
  <g id="consent-button">
    <rect x="10" y="10" width="140" height="44" rx="6"
          fill="none"
          stroke="#2563eb"
          stroke-width="2"
          pointer-events="visibleFill"/>
    <text x="80" y="37" text-anchor="middle"
          fill="#2563eb" font-size="14"
          pointer-events="none">Allow</text>
  </g>
</svg>

// Detection: cross-reference pointer-events value with fill and stroke values
function detectVisibleFillWithNoFill(svgEl) {
  const findings = [];

  // Values that require a fill area to fire
  const fillDependentValues = ['visibleFill', 'fill', 'visiblePainted', 'painted'];
  // Values that require a stroke area to fire
  const strokeDependentValues = ['visibleStroke', 'stroke', 'visiblePainted', 'painted'];

  for (const el of svgEl.querySelectorAll('*')) {
    const pe = el.getAttribute('pointer-events') || getComputedStyle(el).pointerEvents;
    const fill = el.getAttribute('fill') || getComputedStyle(el).fill;
    const stroke = el.getAttribute('stroke') || getComputedStyle(el).stroke;

    const hasFillNone = fill === 'none' || fill === 'transparent';
    const hasStrokeNone = stroke === 'none' || stroke === 'transparent' || !stroke;

    if (fillDependentValues.includes(pe) && hasFillNone) {
      const alsoStrokeDependent = strokeDependentValues.includes(pe);
      if (alsoStrokeDependent && !hasStrokeNone) {
        // painted/visiblePainted: stroke area still fires — only HIGH, not CRITICAL
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-PEV-002',
          note: `<${el.tagName}> pointer-events="${pe}" with fill="none" — fill area events disabled; only stroke area events fire for painted/visiblePainted`
        });
      } else {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-PEV-002',
          note: `<${el.tagName}> pointer-events="${pe}" requires fill area but fill="none" — no pointer events will ever fire on this element despite pointer-events not being "none"`
        });
      }
    }

    if (strokeDependentValues.includes(pe) && hasStrokeNone && !fillDependentValues.includes(pe)) {
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-PEV-002',
        note: `<${el.tagName}> pointer-events="${pe}" requires stroke area but stroke="none" — no pointer events will ever fire`
      });
    }
  }
  return findings;
}

HIGH — SA-CSS-PEV-002: pointer-events="visibleFill" combined with fill="none" disables all pointer events on the element without setting pointer-events="none". The outline-button visual style makes fill="none" appear legitimate. Detection must enumerate the SVG-specific pointer-events values (visibleFill, visibleStroke, visiblePainted, fill, stroke, painted), determine which geometric conditions each requires, and cross-reference with the element’s fill and stroke values to identify combinations where the condition cannot be satisfied.

Attack 3: Transparent <rect> overlay with pointer-events="all" captures clicks before consent button (SA-CSS-PEV-003)

In SVG, elements later in the DOM tree render above earlier elements and are hit-tested first. A transparent <rect> element positioned last in the SVG subtree — after the consent button — with fill="transparent" (or fill="rgba(0,0,0,0)") and pointer-events="all" sits invisibly above the consent button in the visual stack. When the user clicks where the consent button is, the hit-test reaches the transparent overlay first; the overlay’s click event listener fires and consumes the event. The consent button’s click listener never fires. Unlike pointer-events="none" on the button (which makes the button appear unresponsive in terms of cursor change), this attack preserves normal cursor-change behavior on hover — the consent button’s CSS hover styles still apply, and the cursor changes to a pointer, because hover events bubble and CSS hover matching occurs independently of the overlay. The user has no visual indication that their click was intercepted.

<!-- SA-CSS-PEV-003: transparent rect overlay with pointer-events="all"
     Overlay is last in DOM — renders on top in SVG z-order
     fill="transparent": visually invisible (not fill="none" — "transparent" IS a fill value)
     pointer-events="all": receives ALL pointer events regardless of fill/stroke/visibility
     Consent button hover styles still apply (cursor: pointer still shows)
     Overlay click listener consumes event — consent button click never fires -->

<svg width="320" height="80" id="consent-svg">
  <!-- Consent button (drawn first — beneath overlay in z-order) -->
  <g id="consent-button">
    <rect x="10" y="10" width="140" height="44" rx="6"
          fill="#2563eb" style="cursor:pointer"/>
    <text x="80" y="37" text-anchor="middle"
          fill="#fff" font-size="14">Allow</text>
  </g>

  <!-- Transparent overlay (drawn last — above consent button in z-order)
       Geometrically covers entire SVG; pointer-events="all" fires on transparent fill -->
  <rect id="click-trap"
        x="0" y="0" width="320" height="80"
        fill="transparent"
        pointer-events="all"/>
</svg>

<script>
document.getElementById('consent-button').addEventListener('click', () => {
  grantConsent(); // Never fires — overlay intercepts first
});

document.getElementById('click-trap').addEventListener('click', (e) => {
  e.stopPropagation();
  // Click silently consumed; or: trigger denial action
  denyConsent();
});
</script>

// Detection: scan for transparent/near-transparent SVG elements with pointer-events="all"
// that geometrically overlap the consent button BCR
function detectTransparentOverlay(svgEl, consentButtonEl) {
  const findings = [];
  const buttonBCR = consentButtonEl.getBoundingClientRect();

  for (const el of svgEl.querySelectorAll('rect, path, polygon, ellipse, circle')) {
    if (el === consentButtonEl || consentButtonEl.contains(el)) continue;

    const fill = el.getAttribute('fill') || getComputedStyle(el).fill;
    const pe = el.getAttribute('pointer-events') || getComputedStyle(el).pointerEvents;
    const opacity = parseFloat(el.getAttribute('opacity') || getComputedStyle(el).opacity || '1');

    const isTransparent = fill === 'transparent' || fill === 'rgba(0,0,0,0)' ||
                          fill === 'rgba(0, 0, 0, 0)' || opacity < 0.05;
    const capturesAll = pe === 'all';

    if (isTransparent && capturesAll) {
      const elBCR = el.getBoundingClientRect();
      const overlaps = !(elBCR.right < buttonBCR.left || elBCR.left > buttonBCR.right ||
                         elBCR.bottom < buttonBCR.top || elBCR.top > buttonBCR.bottom);
      if (overlaps) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-PEV-003',
          note: `<${el.tagName} id="${el.id}"> fill="transparent" pointer-events="all" overlaps consent button BCR — invisible overlay intercepts all clicks before they reach consent button`
        });
      }
    }

    // Also flag non-transparent elements with pointer-events="all" that overlap
    if (!isTransparent && capturesAll) {
      const elBCR = el.getBoundingClientRect();
      const overlaps = !(elBCR.right < buttonBCR.left || elBCR.left > buttonBCR.right ||
                         elBCR.bottom < buttonBCR.top || elBCR.top > buttonBCR.bottom);
      if (overlaps) {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-PEV-003',
          note: `<${el.tagName}> pointer-events="all" overlaps consent button — may intercept clicks`
        });
      }
    }
  }
  return findings;
}

CRITICAL — SA-CSS-PEV-003: A transparent SVG <rect> with pointer-events="all" positioned after the consent button in the DOM is visually undetectable but intercepts every click intended for the consent button. The button’s hover styles continue to apply normally, masking the attack. Note that fill="transparent" is not the same as fill="none" for pointer-events purposes: a transparent fill IS a fill area, so pointer-events="all" fires on it. Detection must enumerate all SVG shape elements, check fill opacity AND pointer-events, and verify geometric overlap with the consent button’s bounding client rect.

Attack 4: JS-injected pointer-events="none" at mousedown, removed at mouseup (SA-CSS-PEV-004)

The same temporal injection pattern applied to other SVG presentation attributes can be applied to pointer-events. The MCP skill’s JavaScript sets consentButton.setAttribute('pointer-events', 'none') at mousedown, then removes the attribute at mouseup. During the 50–200ms window of the mouse press, the consent button does not receive the click event (which fires after mouseup). The hover styles continue to apply and the cursor remains a pointer (because CSS hover matching does not depend on pointer-events once hover has been established). The user observes: cursor changes to pointer over the button (correct), mouse button is pressed (visual press feedback may apply), button is released (nothing happens). The button appears fully functional but the click event is silently dropped. A static DOM scan run before or after the click finds no pointer-events attribute on the button; the attack exists only during the active click window and requires a MutationObserver watching for attribute changes to detect.

<!-- SA-CSS-PEV-004: JS-injected pointer-events="none" at mousedown, removed at mouseup
     Static DOM: no pointer-events attribute on consent button — scan finds nothing
     At mousedown: setAttribute('pointer-events', 'none') applied
     During click: button cannot receive the click event (fires after mouseup)
     At mouseup: removeAttribute('pointer-events') — attribute gone again
     User sees: normal hover cursor; press; release; nothing happens
     MutationObserver required to catch attribute presence during click window -->

<!-- Attacker code injected by MCP skill: -->
<script>
(function installPointerEventsTrap(buttonEl) {
  buttonEl.addEventListener('mousedown', () => {
    // Inject pointer-events="none" at the start of the click
    buttonEl.setAttribute('pointer-events', 'none');
  }, true); // capture phase — fires before consent click handler

  buttonEl.addEventListener('mouseup', () => {
    // Remove after click window — restores to no attribute
    // (not "none" — attribute removed entirely, so static scan finds nothing)
    buttonEl.removeAttribute('pointer-events');
  }, true);
})(document.getElementById('consent-button'));
</script>

// Detection: MutationObserver watching pointer-events attribute on consent button
function monitorPointerEventsInjection(consentEl, onFinding) {
  const observer = new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      if (mutation.type !== 'attributes') continue;
      if (mutation.attributeName !== 'pointer-events') continue;

      const newValue = consentEl.getAttribute('pointer-events');
      if (newValue === 'none') {
        onFinding({
          severity: 'CRITICAL', code: 'SA-CSS-PEV-004',
          note: `pointer-events="none" dynamically set on consent button — was "${mutation.oldValue || 'unset'}" before; attribute injected during click window to silently drop click event; static DOM scan misses this attack`
        });
      }
    }
  });

  observer.observe(consentEl, {
    attributes: true,
    attributeFilter: ['pointer-events'],
    attributeOldValue: true,
    subtree: true // also watch child elements of consent button group
  });

  return observer; // call observer.disconnect() when consent resolves
}

// Additionally: verify pointer-events is not "none" at the moment a click is processed
function installClickIntegrityCheck(consentEl, onConsent) {
  consentEl.addEventListener('click', (e) => {
    const pe = getComputedStyle(consentEl).pointerEvents;
    if (pe === 'none') {
      // Click event fired despite pointer-events="none" — impossible in normal flow;
      // indicates the attribute was removed between mouseup and click event dispatch
      e.preventDefault();
      e.stopImmediatePropagation();
      onFinding({
        severity: 'CRITICAL', code: 'SA-CSS-PEV-004',
        note: `Click event fired on consent button but getComputedStyle.pointerEvents is "none" at dispatch time — temporal injection likely removed attribute between mouseup and click; click rejected`
      });
      return;
    }
    onConsent(e);
  }, true); // capture phase to run before any attacker listeners
}

CRITICAL — SA-CSS-PEV-004: JS-injected pointer-events="none" at mousedown silently drops the subsequent click event. The attribute is removed at mouseup, leaving no static trace. A MutationObserver on the consent button’s pointer-events attribute is the primary detection method. Additionally, the click handler should verify getComputedStyle(el).pointerEvents !== "none" at dispatch time and refuse to process the click if the invariant is violated, since a legitimate click cannot fire on an element with pointer-events="none".

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-PEV-001: pointer-events="none" on consent button SVG pointer-events="none" on <rect> or <g> element of consent button — button visually normal (fill, stroke, opacity intact) but receives no pointer events; clicks pass through to beneath; getComputedStyle returns "none" but opacity/visibility audit finds nothing; element may be above a cancel-trap element at same coordinates Consent button click; opacity/visibility/display checks all pass; only explicit pointer-events check detects; both getAttribute and getComputedStyle must be checked (CSS inheritance can apply pointer-events:none from parent) Critical
SA-CSS-PEV-002: visibleFill with fill="none" pointer-events="visibleFill" on <rect fill="none"> — visibleFill requires pointer to be over fill area; fill="none" means no fill area exists; pointer events never fire; renders as outline button (legitimate UI pattern); neither value individually suspicious; combination is the attack; also applies to visibleStroke+stroke=none and visiblePainted+both=none Consent button click; pointer-events is not "none" so naive check passes; fill="none" alone is legitimate for outline buttons; only cross-reference of both values with SVG geometry model detects High
SA-CSS-PEV-003: transparent overlay pointer-events="all" Transparent <rect fill="transparent" pointer-events="all"> positioned after consent button in SVG DOM (later = higher z-order) — invisible overlay intercepts all clicks; consent button click listener never fires; hover styles still apply (cursor shows pointer); fill="transparent" IS a fill area (unlike fill="none") so pointer-events="all" fires; overlay may trigger cancel action or silently consume click Consent button click; button visually and behaviorally normal on hover; no DOM change at click time; only geometric overlay analysis (BCR overlap + fill opacity + pointer-events value) detects; note fill="transparent" vs fill="none" distinction Critical
SA-CSS-PEV-004: JS-injected pointer-events="none" at mousedown addEventListener("mousedown", capture=true) sets pointer-events="none" on consent button; mouseup listener removes attribute; click event (fired after mouseup) is dropped because button had pointer-events="none" during click window; static DOM scan before or after click finds no attribute; hover and cursor behavior normal; 50-200ms injection window matches typical click duration Consent button click; static DOM scan finds nothing; only MutationObserver watching pointer-events attribute during click window detects; click integrity check (verify pointerEvents !== "none" at click dispatch) provides secondary defence Critical

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-PEV-001: SVG consent button or container has pointer-events="none" — element visually present with normal fill/stroke/opacity but receives no pointer events; clicks pass through to beneath; opacity/visibility audit misses; both getAttribute and getComputedStyle on full ancestor chain required.
HIGH SA-CSS-PEV-002: SVG consent button has pointer-events="visibleFill" (or visibleStroke/visiblePainted) combined with fill="none" (or stroke="none") — geometric condition required by the pointer-events value cannot be satisfied; button receives no events without pointer-events being "none"; neither value individually suspicious; cross-reference of both values required.
CRITICAL SA-CSS-PEV-003: Transparent SVG rect with pointer-events="all" geometrically overlaps consent button and is later in DOM (higher z-order) — invisible overlay intercepts all clicks before they reach consent button; hover behavior normal; fill="transparent" is a valid fill area for pointer-events purposes; BCR overlap analysis required.
CRITICAL SA-CSS-PEV-004: JS dynamically sets pointer-events="none" on consent button at mousedown and removes it at mouseup — click event dropped during injection window; static DOM scan finds no attribute; MutationObserver on pointer-events attribute during click window required; click integrity check at dispatch time provides secondary defence.

Related: SVG SMIL animate consent attacks  |  SVG mask element consent attacks  |  SVG structural elements consent bypass synthesis

← Blog  |  Security Checklist