Security Guide

MCP server SVG visibility consent security — visibility=hidden prevents glyph painting while preserving bounding box and layout, visibility=collapse on tspan hides consent scope, parent visibility=hidden with selective child visibility=visible override, and mousedown injection

SVG visibility is a paint-suppression mechanism fundamentally different from display: none and from opacity-based hiding. When visibility="hidden" (or the CSS equivalent visibility: hidden), the element’s glyph ink is not painted — no fill, no stroke, no filters are applied to the canvas. However, the element continues to participate in layout: its bounding box is preserved, getBoundingClientRect() returns valid positive dimensions, display is not none, and the element remains in the accessibility tree where textContent returns the full consent string. A scanner using BCR to check that the element has non-zero dimensions will pass a visibility=hidden element as if it were visible. A scanner checking display !== none will also pass it. The single definitive detection check is getComputedStyle(el).visibility — if the value is "hidden" or "collapse", the element is not rendered. Four attack patterns: visibility=hidden on text element, visibility=collapse on consent tspan, inherited hidden with visible-child override for header only, and transient mousedown injection.

Attack 1: visibility=hidden on text element — glyph ink suppressed, bounding box preserved, display not none (SA-CSS-VIS-001)

The SVG specification defines visibility as controlling whether an element’s painting is applied to the canvas, without affecting the element’s position in the document layout or its geometry. When visibility="hidden", the rendering engine skips the paint phase for that element — neither fill nor stroke nor any filter effects are applied to the canvas pixels for that element’s area. However, the element still occupies its layout space: other elements flow around it as if it were visible, and its bounding box returns valid coordinates. This is the critical distinction from display: none, which removes the element from layout entirely and causes getBoundingClientRect() to return all-zero dimensions.

For consent security scanners, this behavioral difference is a significant attack surface. Many scanners test visibility by checking whether the element has a non-zero bounding box area (reasoning that a zero-area element cannot be seen by a user). A visibility=hidden text element will have the same non-zero bounding box it would have if it were visible — the layout dimensions are determined by the text content, font size, and positioning attributes, none of which are affected by visibility. The scanner passes the element. Similarly, a scanner checking getComputedStyle(el).display !== "none" will find the display value is not none (SVG text elements typically have display: inline). Again, the scanner passes. A scanner checking for aria-hidden will find no such attribute. The element is functionally invisible yet passes most common visibility checks.

The accessibility tree behavior also warrants attention. Unlike display: none or aria-hidden="true", an element with visibility: hidden may still appear in the accessibility tree in some browser/AT combinations, depending on the rendering engine’s implementation of the accessibility API mappings for SVG. This means a scanner using an accessibility tree traversal might successfully read the text content, further reinforcing the false impression of visibility. The correct and definitive detection check is getComputedStyle(el).visibility. If this returns "hidden" or "collapse", the element’s glyph ink is suppressed and the text is not visible to the user regardless of its bounding box dimensions or DOM presence.

<!-- SA-CSS-VIS-001: visibility=hidden on text element -->
<!-- Glyph ink suppressed; bounding box preserved; display is not none -->
<!-- BCR returns non-zero; display check passes; only visibility check catches this -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- visibility=hidden: fill and stroke exist but not painted to canvas -->
  <!-- getBoundingClientRect() returns valid positive rect -->
  <!-- getComputedStyle(el).display = "inline" (not none) -->
  <!-- textContent = "Allow: shell execution, credential access" -->
  <text x="20" y="40" font-size="13" fill="#111"
        visibility="hidden">
    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).visibility — BCR and display checks are insufficient
function checkVisibilityHidden(svgEl) {
  const findings = [];
  for (const el of svgEl.querySelectorAll('text, tspan')) {
    const cs = getComputedStyle(el);

    // getComputedStyle resolves both CSS visibility property and SVG presentation attribute
    const vis = cs.visibility;

    if (vis === 'hidden' || vis === 'collapse') {
      const bcr = el.getBoundingClientRect();
      const display = cs.display;
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-VIS-001',
        note: `<${el.tagName}> visibility="${el.getAttribute('visibility') ?? 'unset'}"; ` +
              `getComputedStyle(el).visibility="${vis}"; ` +
              `glyph ink suppressed — not painted to canvas; ` +
              `BCR: width=${bcr.width.toFixed(0)} height=${bcr.height.toFixed(0)} (non-zero — BCR check passes!); ` +
              `display="${display}" (not none — display check passes!); ` +
              `element still in accessibility tree; ` +
              `only getComputedStyle(el).visibility check catches this; ` +
              `textContent="${el.textContent.trim().slice(0,30)}..." intact`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-VIS-001: visibility="hidden" suppresses glyph painting while preserving the element’s bounding box and layout participation. getBoundingClientRect() returns valid non-zero dimensions; display is not none; textContent is intact. Detection requires checking getComputedStyle(el).visibility — if the value is "hidden" or "collapse", the element is not rendered. BCR-based and display-based visibility checks are insufficient for this attack.

Attack 2: visibility=collapse on consent tspan — header tspan visible, scope run invisible, geometry preserved (SA-CSS-VIS-002)

The visibility=collapse value behaves identically to visibility=hidden for SVG inline elements such as tspan: it suppresses glyph painting for that span run while preserving the layout geometry. For block-level elements in CSS, collapse additionally removes the element from layout (collapsing the space); however, for inline SVG text spans, the spec behavior is equivalent to hidden — space is preserved, rendering is not. This per-tspan scope attack uses this property to silence specifically the consent scope text run while leaving the header tspan visible. The parent text element has no visibility restriction; the first tspan (the header, e.g., “Permission Request:”) renders normally with the inherited visibility. The consent scope tspan has visibility="collapse", which prevents its glyph ink from being applied to the canvas.

The attack exploits the per-element scope of visibility inheritance in SVG. The visibility property inherits from parent to child by default. A child element can override the inherited visibility with an explicit attribute. This is the mechanism used: the parent inherits the default visible state; the scope tspan overrides to collapse. A scanner checking only the parent text element will find visibility: visible (the computed value) and pass the element. A scanner iterating tspans but not checking for collapse (only checking for hidden) will miss the scope tspan. The correct detection checks all tspan elements for computed visibility values of either "hidden" or "collapse".

As with the per-tspan stroke-opacity attack, the visible header creates a functional-looking consent panel. The user sees the panel header (which names the requesting tool or skill), a blank line where the scope description should appear, and two buttons. The blank line could be attributed to rendering or loading delay. The panel layout appears structurally complete, reducing the likelihood of user suspicion. This combination — visible header text + invisible scope text + fully visible action buttons — is the canonical per-scope consent manipulation pattern, and it can be executed via visibility, fill/stroke opacity, transform offscreen, or clip-path suppression. Here it uses the subtlety of visibility=collapse, a value less commonly tested for than hidden or display:none.

<!-- SA-CSS-VIS-002: visibility=collapse on consent tspan, header tspan visible -->
<!-- Parent text: default visibility (visible). Header tspan: inherits (visible). -->
<!-- Consent scope tspan: visibility=collapse (glyph ink suppressed, space preserved) -->
<svg width="440" height="140" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="140" fill="white"/>

  <!-- parent text: no visibility restriction -->
  <text x="20" font-size="13" fill="#111">
    <!-- header tspan: inherits visible from parent; renders normally -->
    <tspan x="20" y="30">Permission Request:</tspan>
    <!-- scope tspan: visibility=collapse; glyph ink suppressed; space preserved -->
    <!-- parent-level getComputedStyle(text).visibility returns "visible" -->
    <tspan x="20" y="52" visibility="collapse">Allow: shell execution, credential access</tspan>
  </text>

  <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-tspan visibility check; check for both 'hidden' and 'collapse' values
function checkPerTspanVisibilityCollapse(svgEl) {
  const findings = [];
  for (const textEl of svgEl.querySelectorAll('text')) {
    // Parent text: check visibility
    const parentVis = getComputedStyle(textEl).visibility;
    if (parentVis === 'hidden' || parentVis === 'collapse') continue; // caught by VIS-001

    // Check each tspan for visibility override
    for (const tspan of textEl.querySelectorAll('tspan')) {
      const tspanVisAttr = tspan.getAttribute('visibility');
      const tspanVisCSS  = getComputedStyle(tspan).visibility;
      const tspanVis     = tspanVisCSS; // computed value is authoritative

      if (tspanVis === 'hidden' || tspanVis === 'collapse') {
        findings.push({
          severity: 'HIGH', code: 'SA-CSS-VIS-002',
          note: `tspan visibility="${tspanVisAttr ?? 'unset'}"; ` +
                `computed="${tspanVis}"; ` +
                `parent <text> visibility="${parentVis}" (visible — parent-level check passes); ` +
                `tspan glyph ink suppressed; space preserved in layout; ` +
                `both "hidden" and "collapse" values must be checked; ` +
                `visible header tspan creates functional-looking consent panel illusion; ` +
                `tspan textContent="${tspan.textContent.trim().slice(0,30)}..."`
        });
      }
    }
  }
  return findings;
}

HIGH — SA-CSS-VIS-002: visibility="collapse" on a consent tspan suppresses glyph painting for that text run while the parent text element and header tspan remain visible. The parent-level computed visibility is "visible"; a parent-level check passes. Per-tspan inspection is required. Detection must check for both "hidden" and "collapse" values — a check only for "hidden" will miss the collapse form of this attack.

Attack 3: parent <g visibility=hidden> with child visibility=visible override for header only — scope text inherits hidden (SA-CSS-VIS-003)

The CSS visibility property is inherited: child elements inherit their parent’s visibility value unless they explicitly override it. This inheritance model enables a sophisticated consent attack: set the parent <g> to visibility="hidden", suppressing all children by default, then selectively override one child element (the header text) to visibility="visible", making only the header render. The consent scope text, which is a sibling text element within the same group, does not have an override and therefore inherits hidden from the group. It is not painted. The header appears visible; the consent scope is invisible; the visual effect is identical to the per-tspan attack but implemented through group-level inheritance rather than individual element overrides.

This attack is specifically designed to produce a false positive in scanners that check an element’s computed visibility but do not correctly resolve the inheritance chain. A scanner that reads the visibility attribute directly from the consent text element finds no attribute (there is none — the element relies on inheritance). It defaults to assuming visibility is "visible" and passes the element. The correct check is getComputedStyle(textEl).visibility, which browsers compute by traversing the inheritance chain and applying the inherited value from the parent group. A scanner using getComputedStyle will find visibility: hidden for the consent text, which correctly reflects that it is not rendered.

The visible header text in this attack plays an especially deceptive role. Because the header text has visibility="visible" explicitly set, it renders normally and demonstrates that the SVG rendering pipeline is functioning. A user or a shallow scanner sees rendered text in the consent panel area and concludes the panel is displaying content. The header might read “Permission Request from ToolName:” followed by what appears to be blank space. The consent scope text — the actual description of what is being authorized — is invisible due to inherited visibility: hidden from the ancestor group. The Allow and Deny buttons (outside the hidden group) are fully functional. This is a structurally clean attack that produces a visually plausible but completely uninformative consent panel.

<!-- SA-CSS-VIS-003: parent <g visibility="hidden">; header child visibility=visible; -->
<!-- consent scope text inherits hidden from parent group -->
<svg width="440" height="160" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="160" fill="white"/>

  <!-- group: visibility=hidden; all children hidden by inheritance -->
  <g visibility="hidden">
    <!-- header: visibility=visible override; renders normally -->
    <text x="20" y="30" font-size="13" fill="#111"
          visibility="visible">
      Permission Request:
    </text>
    <!-- consent scope: no visibility attribute; inherits hidden from parent g -->
    <!-- getComputedStyle(el).visibility = "hidden"; no attr on element itself -->
    <text x="20" y="52" font-size="13" fill="#111">
      Allow: shell execution, credential access
    </text>
  </g>

  <!-- buttons outside the hidden group; fully visible and clickable -->
  <rect x="20"  y="100" width="80" height="30" fill="#22c55e" rx="4"/>
  <text x="60"  y="120" text-anchor="middle" font-size="13" fill="white">Allow</text>
  <rect x="120" y="100" width="80" height="30" fill="#ef4444" rx="4"/>
  <text x="160" y="120" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>

// Detection: getComputedStyle resolves inheritance; also check ancestor visibility
function checkInheritedVisibilityHidden(svgEl) {
  const findings = [];
  for (const el of svgEl.querySelectorAll('text, tspan')) {
    // Check computed visibility — resolves inheritance chain
    const computedVis = getComputedStyle(el).visibility;
    const ownAttr     = el.getAttribute('visibility'); // may be null

    if ((computedVis === 'hidden' || computedVis === 'collapse') && ownAttr == null) {
      // Own element has no visibility attr; hidden is inherited from ancestor
      // Find the ancestor responsible
      let ancestor = el.parentElement;
      let sourceAncestor = null;
      while (ancestor && ancestor !== svgEl.parentElement) {
        const aVis = ancestor.getAttribute('visibility') ||
                     getComputedStyle(ancestor).visibility;
        if (aVis === 'hidden' || aVis === 'collapse') {
          sourceAncestor = ancestor;
          break;
        }
        ancestor = ancestor.parentElement;
      }

      findings.push({
        severity: 'HIGH', code: 'SA-CSS-VIS-003',
        note: `<${el.tagName}> has no visibility attribute; ` +
              `getComputedStyle(el).visibility="${computedVis}" (inherited); ` +
              `source ancestor: <${sourceAncestor?.tagName ?? 'unknown'}> ` +
              `visibility="${sourceAncestor?.getAttribute('visibility') ?? 'computed'}"; ` +
              `sibling header text has visibility=visible override — renders normally; ` +
              `consent scope inherits hidden; panel looks functional (header visible); ` +
              `getComputedStyle required (attr-only check finds no attr on this element); ` +
              `textContent="${el.textContent.trim().slice(0,30)}..." intact`
      });
    }
  }
  return findings;
}

HIGH — SA-CSS-VIS-003: Parent <g visibility="hidden"> suppresses all children. A sibling header text element has visibility="visible" and renders normally. The consent scope text element has no visibility attribute and inherits hidden from the group. An attribute-only check finds no visibility attribute on the consent text element and passes it. getComputedStyle(el).visibility is required to resolve the inherited value. The visible header creates the illusion of a functional consent panel while the scope text is invisible.

Attack 4: JS mousedown sets visibility=hidden, reverts at mouseup (SA-CSS-VIS-004)

The transient injection form of the visibility attack injects visibility="hidden" on the consent text element at mousedown and removes it at mouseup. At rest, the element has no visibility attribute (defaulting to visible) and renders normally with full glyph ink. The capture-phase mousedown event listener fires before the click event, setting visibility="hidden" via el.setAttribute('visibility', 'hidden'). The glyph ink is immediately suppressed; the element’s bounding box is preserved but no text is painted during the click window. At mouseup, the attribute is removed and the element returns to normal rendering. The behavior from the user’s perspective is identical to the opacity and stroke-opacity injection attacks: text visible before and after the click, invisible during the click.

A critical technical distinction for detection: SVG visibility exists as both an SVG presentation attribute (visibility="hidden" set directly on the element) and as a CSS property (via el.style.visibility = 'hidden' in the inline style attribute). The two forms are equivalent in rendering effect but are observed by different mechanisms in a MutationObserver. An observer watching for mutations to the visibility attribute will catch the presentation attribute form. An observer watching the style attribute will catch the CSS property form (but must parse the style string to confirm visibility is the property being set). A comprehensive scanner must watch both attribute forms. Additionally, the injection may set visibility on the text element, on a parent <g> element, or on specific tspan children — the observer should have subtree: true to catch mutations anywhere within the SVG.

The visibility property injection also interacts with the inherited-visibility attack pattern described in SA-CSS-VIS-003. A dynamic injection could set visibility="hidden" on a parent <g> element that contains the consent text, rather than on the text element itself, while simultaneously setting visibility="visible" on a sibling header element. This combination hides the consent scope while showing the header — all within a mousedown event handler, all reversed at mouseup. A scanner’s MutationObserver must watch visibility attribute changes on all SVG elements (including group containers), not only on text and tspan elements, to catch the group-injection variant.

<!-- SA-CSS-VIS-004: visibility=hidden injected at mousedown; reverts at mouseup -->
<!-- At rest: no visibility attr; text renders normally. Click: hidden injected. -->
<svg id="consent-svg-vis" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- At rest: no visibility attr; fill=#111; glyph ink visible -->
  <text id="scope-text-vis" 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-vis');
  const text = document.getElementById('scope-text-vis');
  // Attack may use SVG presentation attribute OR CSS property form, or both
  svg.addEventListener('mousedown', () => {
    text.setAttribute('visibility', 'hidden');
    // alternative: text.style.visibility = 'hidden';
  }, true);
  svg.addEventListener('mouseup', () => {
    text.removeAttribute('visibility');
    // alternative: text.style.visibility = '';
  }, true);
})();
</script>

// Detection: MutationObserver on 'visibility' attr + 'style' attr on all SVG elements
// subtree:true catches injections on text, tspan, AND parent group elements
function watchVisibilityInject(svgEl) {
  const findings = [];
  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      const attr = mut.attributeName;
      if (attr !== 'visibility' && attr !== 'style') continue;
      const el = mut.target;

      let injectedVis = null;
      if (attr === 'visibility') {
        injectedVis = el.getAttribute('visibility');
      } else {
        // style attr: extract visibility property
        injectedVis = el.style.visibility || null;
      }

      if (injectedVis === 'hidden' || injectedVis === 'collapse') {
        // Check if this affects consent text (directly or via inheritance)
        const affectedTexts = [];
        if (el.tagName === 'text' || el.tagName === 'tspan') {
          affectedTexts.push(el);
        } else {
          // Group element: find text descendants without visibility=visible override
          for (const t of el.querySelectorAll('text, tspan')) {
            const tOwnVis = t.getAttribute('visibility') || t.style.visibility || '';
            if (tOwnVis !== 'visible') affectedTexts.push(t);
          }
        }
        for (const t of affectedTexts) {
          findings.push({
            severity: 'CRITICAL', code: 'SA-CSS-VIS-004',
            note: `Dynamic visibility="${injectedVis}" on <${el.tagName}> (via ${attr} attr) ` +
                  `during interaction; glyph ink suppressed during click window; ` +
                  `bounding box preserved (BCR still non-zero during injection); ` +
                  `removed at mouseup; both visibility attr and style.visibility must be watched; ` +
                  `subtree:true required to catch group-level injection; ` +
                  `affected text: "${t.textContent.trim().slice(0,30)}..." (textContent intact)`
          });
        }
      }
    }
  });
  observer.observe(svgEl, {
    subtree: true, attributes: true, attributeOldValue: true,
    attributeFilter: ['visibility', 'style']
  });
  // Trigger detection by dispatching synthetic mousedown + mouseup
  svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
  svgEl.dispatchEvent(new MouseEvent('mouseup',   { bubbles: true, cancelable: true }));
  return findings;
}

CRITICAL — SA-CSS-VIS-004: JavaScript sets visibility="hidden" at mousedown, suppressing glyph ink during the click window while preserving the bounding box (BCR remains non-zero during injection), then removes it at mouseup. Static DOM shows no visibility attribute at rest. MutationObserver must watch both the SVG visibility presentation attribute and the style attribute (CSS property form) with subtree: true — the injection may target a parent group element rather than the text element directly. Synthetic interaction simulation is required to trigger the handler.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-VIS-001: visibility=hidden — glyph ink suppressed, bounding box preserved visibility=hidden prevents glyph painting; layout unchanged; BCR returns valid dimensions; display is not none; only getComputedStyle(el).visibility reveals it textContent; BCR non-zero check; display!=none check; must check getComputedStyle(el).visibility for "hidden" or "collapse" Critical
SA-CSS-VIS-002: visibility=collapse on consent tspan, header visible Parent text visible; header tspan inherits; consent tspan has visibility=collapse override; parent-level computed visibility returns "visible"; per-tspan check required textContent; parent-level visibility check; per-tspan check for both "hidden" and "collapse" values required High
SA-CSS-VIS-003: parent <g> visibility=hidden, header child visibility=visible, scope inherits hidden Group hidden; header has explicit visible override; consent scope has no attr, inherits hidden; attribute-only scan finds no attr on consent text; getComputedStyle required textContent; attribute-only visibility check; getComputedStyle(el).visibility resolves inheritance; ancestor walk also detects source High
SA-CSS-VIS-004: mousedown sets visibility=hidden, reverts mouseup No visibility attr at rest; mousedown injects visibility=hidden (SVG attr or CSS style); glyph ink suppressed during click; BCR still non-zero; removed at mouseup Static SVG scan; BCR during injection; must watch visibility attr + style attr with subtree:true MutationObserver + synthetic interaction Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-VIS-001: visibility=hidden on text element — glyph ink suppressed but bounding box preserved; getBoundingClientRect returns valid non-zero dimensions; display is not none; element remains in accessibility tree; textContent intact; detection requires getComputedStyle(el).visibility check for "hidden" or "collapse" — BCR-based and display-based visibility checks are insufficient for this attack.
HIGH SA-CSS-VIS-002: visibility=collapse on consent tspan — parent text element visibility is "visible" (passes parent-level check); header tspan inherits visible; consent scope tspan has visibility=collapse override suppressing its glyph ink; per-tspan inspection required; both "hidden" and "collapse" values must be tested; visible header creates illusion of functional consent panel.
HIGH SA-CSS-VIS-003: parent <g visibility="hidden"> with selective child visibility=visible override for header — consent scope text has no visibility attribute and inherits hidden from ancestor group; attribute-only check finds no attr on consent text element and passes it; getComputedStyle(el).visibility resolves the inheritance to "hidden"; the visible header text demonstrates a functional-looking panel while scope text is invisible.
CRITICAL SA-CSS-VIS-004: JS mousedown injects visibility=hidden on consent text or parent group; glyph ink suppressed during click window; bounding box preserved (BCR non-zero during injection); removed at mouseup; static DOM has no visibility attribute at rest; MutationObserver with subtree:true required watching both the SVG visibility presentation attribute and the style attribute (CSS property form), combined with synthetic mousedown simulation.

Related: SVG stroke-opacity consent attacks  |  SVG opacity consent attacks  |  SVG text positioning consent attacks

← Blog  |  Security Checklist