Security Guide

MCP server SVG opacity consent security — opacity=0 makes entire element transparent regardless of fill and stroke values, near-zero opacity=0.04 below perceptual threshold, opacity=0 on parent <g> group hides entire consent row, and mousedown injection

SVG opacity is a compositing-level property that is applied after an element has been rendered internally with its fill and stroke paint. Unlike fill-opacity (which scales down only the fill paint alpha) and stroke-opacity (which scales down only the stroke paint alpha), the opacity property treats the entire element — including both fill and stroke contributions — as a single compositing unit and applies a final alpha multiplier before placing it onto the canvas. Setting opacity="0" on a <text> element with a dark fill and a visible stroke makes the entire rendered text completely transparent. getComputedStyle(el).fill and getComputedStyle(el).stroke return normal visible values; the element’s bounding box is non-zero; textContent is intact. Only getComputedStyle(el).opacity reveals the deception. Four attack patterns: opacity=0 directly on the text element, near-zero opacity=0.04, opacity=0 on a parent <g> group, and transient mousedown injection.

Attack 1: opacity=0 on text element — entire element compositing alpha zero, fill and stroke values look normal (SA-CSS-OPA-001)

The SVG rendering model for a <text> element proceeds in three stages. First, the element is painted internally: fill color is applied to glyph interiors, stroke color is applied to glyph outlines, and any filters or effects are applied to produce a fully rendered bitmap of the text. Second, the element’s opacity value is used as a final alpha multiplier: the rendered bitmap is composited onto the parent canvas with an overall transparency equal to opacity. Third, the parent canvas receives the composited result. When opacity="0", the alpha multiplier is 0.0, and the fully-rendered text bitmap — regardless of how vivid or opaque its internal fill and stroke colors are — is composited at zero transparency. It is as if the text element were never painted onto the canvas.

This compositing-level property is the most powerful invisibility mechanism in SVG because it operates above the paint channels. A text element with fill="#111" (near-black, strong contrast against white), stroke="#333" (dark grey), and opacity="0" is completely invisible. A scanner that reads the fill color and computes contrast against the background will find a high-contrast ratio and report the text as visible — the fill value is correct and would be visible at opacity=1. Similarly, a scanner checking getComputedStyle(el).display will find it is not “none”; a BCR check returns valid positive dimensions; the accessibility tree contains the text. The single definitive detection check is getComputedStyle(el).opacity (or parsing the opacity attribute on the element). If opacity < 0.1, the element’s entire rendered output is effectively invisible regardless of its paint channel values.

One important nuance is that the opacity property in SVG serves dual roles: it is both a CSS property (set via stylesheet rules or style attribute) and a presentation attribute (set directly as opacity="0" on the element). Both forms produce the same rendering behavior. getComputedStyle(el).opacity resolves both the CSS property form and the presentation attribute form. However, a scanner using el.getAttribute('opacity') only reads the presentation attribute form, missing CSS-rule-applied opacity. A scanner using only el.style.opacity misses both the presentation attribute form and CSS cascade. The correct approach is getComputedStyle(el).opacity, which correctly resolves the fully cascaded value.

<!-- SA-CSS-OPA-001: opacity=0 on text element -->
<!-- Entire element compositing alpha=0; fill and stroke look normal in DOM -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- fill=#111 (high contrast), stroke visible — both paint channels normal -->
  <!-- opacity=0: entire rendered element composited at alpha=0; text invisible -->
  <!-- getComputedStyle(el).fill returns "rgb(17,17,17)"; only opacity reveals it -->
  <text x="20" y="40" font-size="13"
        fill="#111"
        stroke="#333"
        stroke-width="0.3"
        opacity="0">
    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).opacity — resolves both CSS and presentation attribute forms
function checkElementOpacity(svgEl) {
  const findings = [];
  for (const el of svgEl.querySelectorAll('text, tspan')) {
    const cs = getComputedStyle(el);

    // getComputedStyle resolves the fully cascaded opacity (CSS property + presentation attr)
    const opacityCSS = cs.opacity;
    const opacityAttr = el.getAttribute('opacity');
    // Use computed style as authoritative; also read attr for diagnostic detail
    const opacity = parseFloat(opacityCSS ?? opacityAttr ?? '1');

    if (opacity < 0.1) {
      const fill   = el.getAttribute('fill')   || cs.fill   || '';
      const stroke = el.getAttribute('stroke') || cs.stroke || '';
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-OPA-001',
        note: `<${el.tagName}> opacity="${opacityAttr ?? 'unset (CSS)'}"; ` +
              `getComputedStyle(el).opacity="${opacityCSS}"; ` +
              `fill="${fill}" — paint channel looks normal; ` +
              `stroke="${stroke}" — paint channel looks normal; ` +
              `compositing alpha=0 overrides all paint; entire element invisible; ` +
              `fill contrast check passes (fill is opaque dark); only opacity check catches this; ` +
              `textContent="${el.textContent.trim().slice(0,30)}..." intact`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-OPA-001: opacity="0" on a <text> element applies a zero alpha multiplier at the compositing stage, after fill and stroke paint are applied internally. The fill color is dark (passes contrast check); the stroke is visible; only getComputedStyle(el).opacity reveals the deception. Detection must check opacity independently from fill-opacity and stroke-opacity — it is a distinct compositing-stage property that overrides all paint channel contributions simultaneously.

Attack 2: near-zero opacity=0.04 — 4% element-level compositing, fill and stroke look fully opaque (SA-CSS-OPA-002)

This attack uses a fractional near-zero opacity value instead of exactly zero. At opacity="0.04", the fully-rendered text element (with its dark fill and normal stroke colors) is composited onto the SVG canvas at 4% of its nominal visual contribution. For a text element with fill="#111" (RGB 17,17,17) and a white background (RGB 255,255,255), the resulting pixel color is approximately RGB 253,253,253 — visually indistinguishable from white at normal viewing conditions. The glyph shapes exist mathematically in the compositing but their ink contribution to the canvas is below the contrast threshold for human perception.

The near-zero design again serves to evade threshold-naive scanners. A scanner checking opacity !== 0 as a boolean passes this attack because 0.04 is non-zero. A scanner using a threshold of opacity < 0.01 also passes it. The correct threshold is opacity < 0.1, which covers both the absolute-zero and near-zero family of attacks. This threshold aligns with the WCAG minimum contrast requirement: at 4% element-level opacity, even a perfect black text on white background would produce a rendered contrast ratio near 1.0, which is the same as no contrast (invisible). The element-level opacity multiplier interacts multiplicatively with the alpha values of the fill and stroke channels — an element with fill-opacity="0.5" and opacity="0.04" would render at 2% of the fill’s nominal alpha, which is even further below threshold.

A particularly deceptive aspect of the near-zero opacity attack is that it can coexist with proper-looking HTML or SVG accessibility attributes. Screen readers typically surface textContent from SVG elements regardless of their visual opacity, and the element remains in the accessibility tree and the DOM. Automated accessibility audits using assistive technology simulation may actually pass the element (it has text content, it is not display:none, it is not aria-hidden), while visual users cannot read it at all. This creates a divergence between the accessibility check result (pass) and the visual reality (invisible), which is exactly the condition needed for a consent manipulation attack targeting sighted users clicking through a permission dialog.

<!-- SA-CSS-OPA-002: opacity=0.04 — near-zero on text element with dark fill -->
<!-- 4% compositing: effective pixel color near-white on white background -->
<!-- A scanner checking opacity != 0 passes this; threshold <0.1 required -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- fill and stroke are normal; opacity=0.04 composites entire element at 4% -->
  <!-- effective pixel color ≈ RGB(253,253,253) on white — below perceptual threshold -->
  <text x="20" y="40" font-size="13"
        fill="#111"
        opacity="0.04">
    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: threshold check on opacity; boolean !=0 check insufficient
function checkNearZeroOpacity(svgEl) {
  const findings = [];
  for (const el of svgEl.querySelectorAll('text, tspan')) {
    const cs = getComputedStyle(el);
    const opacityCSS  = cs.opacity;
    const opacityAttr = el.getAttribute('opacity');
    const opacity = parseFloat(opacityCSS ?? opacityAttr ?? '1');

    // boolean opacity!=0 passes 0.04; threshold <0.1 required
    if (opacity > 0 && opacity < 0.1) {
      const fill = el.getAttribute('fill') || cs.fill || '';
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-OPA-002',
        note: `<${el.tagName}> opacity="${opacityAttr ?? 'unset'}"; ` +
              `computed="${opacityCSS}"; ` +
              `fill="${fill}" — paint channel looks opaque and contrasty; ` +
              `element-level compositing at ${(opacity*100).toFixed(1)}% — below perceptual threshold; ` +
              `boolean opacity!=0 check passes this; threshold opacity<0.1 required; ` +
              `note: opacity multiplies with fill-opacity/stroke-opacity multiplicatively; ` +
              `textContent="${el.textContent.trim().slice(0,30)}..." intact`
      });
    }
  }
  return findings;
}

CRITICAL — SA-CSS-OPA-002: opacity="0.04" (4%) composites the entire text element at 4% contribution onto the canvas. A dark fill produces near-white pixels on a white background — below perceptual visibility. A boolean opacity !== 0 check passes this attack. The correct threshold is opacity < 0.1, applied to the value from getComputedStyle(el).opacity parsed as a float in [0,1]. The check must be independent from fill-opacity and stroke-opacity checks.

Attack 3: opacity=0 on parent <g> group — entire consent row hidden, text element itself has no opacity attribute (SA-CSS-OPA-003)

SVG allows opacity to be set on any container element, including <g> (group) elements. When opacity="0" is placed on a <g>, the entire group — all its children, including text elements, shapes, and nested groups — is composited as a unit at zero alpha. Individual child elements within the group may have entirely normal fill colors, stroke colors, and no opacity attributes of their own. The consent text element has opacity not set (defaults to 1) but its parent group has opacity="0". The group is the compositing unit; the group’s zero opacity makes all children invisible regardless of their individual paint values.

This attack is designed to evade element-level opacity scanners. A scanner that iterates over text and tspan elements and reads their opacity attribute or computed style will find the attribute absent (not set, defaults to 1) and the element-level computed opacity as 1. However, getComputedStyle(el).opacity in browsers does inherit the parent’s opacity effect through the compositing model, but the behavior is rendering-context-dependent. The most robust detection is to check all ancestor elements in the DOM tree for opacity values below threshold, or to use el.closest('[opacity]') and inspect found ancestors. An alternative is to use getComputedStyle at the group level directly, which will show opacity: 0 on the <g> element.

A practical consent attack using group opacity might place the consent text and its scope description inside a single <g opacity="0">, while the Allow/Deny buttons are outside the group (and therefore not affected by the group opacity). The buttons remain fully clickable and visible; the consent text is invisible; and a user who sees the buttons without the scope text may click Allow without any comprehension of what they are authorizing. The group-opacity form is also harder to notice in SVG source review because the opacity="0" attribute appears on a structurally innocuous-looking <g> tag several lines above the actual consent text, rather than directly on the text element itself.

<!-- SA-CSS-OPA-003: opacity=0 on parent <g>, not on text element -->
<!-- Text element: no opacity attr (defaults 1). Parent group composited at 0. -->
<!-- Scanner checking only text/tspan opacity misses group-level opacity. -->
<svg width="440" height="140" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="140" fill="white"/>

  <!-- consent group: opacity=0 — entire group composited at zero alpha -->
  <!-- text child has no opacity attribute (looks normal in isolation) -->
  <g opacity="0">
    <text x="20" y="30" font-size="13" fill="#111">
      Permission Request:
    </text>
    <text x="20" y="52" font-size="13" fill="#111">
      Allow: shell execution, credential access
    </text>
  </g>

  <!-- buttons are outside the zero-opacity group — visible and clickable -->
  <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: check opacity on text elements AND all ancestor g/svg elements
function checkAncestorGroupOpacity(svgEl) {
  const findings = [];
  for (const textEl of svgEl.querySelectorAll('text, tspan')) {
    // Check the element itself first
    const elOpacity = parseFloat(getComputedStyle(textEl).opacity ?? '1');
    if (elOpacity < 0.1) continue; // already caught by SA-CSS-OPA-001/002

    // Walk ancestor chain: look for g or svg elements with low opacity
    let ancestor = textEl.parentElement;
    while (ancestor && ancestor !== svgEl.parentElement) {
      if (ancestor.tagName === 'g' || ancestor.tagName === 'svg') {
        const ancestorOpacityAttr = ancestor.getAttribute('opacity');
        const ancestorOpacityCSS  = getComputedStyle(ancestor).opacity;
        const ancestorOpacity = parseFloat(
          ancestorOpacityAttr != null ? ancestorOpacityAttr : (ancestorOpacityCSS || '1')
        );
        if (ancestorOpacity < 0.1) {
          findings.push({
            severity: 'HIGH', code: 'SA-CSS-OPA-003',
            note: `<${textEl.tagName}> itself has no opacity attr (opacity=1); ` +
                  `ancestor <${ancestor.tagName}> has opacity="${ancestorOpacityAttr ?? ancestorOpacityCSS}"; ` +
                  `group compositing makes all children invisible; ` +
                  `text-element-level opacity scan misses this; ` +
                  `ancestor walk or el.closest('[opacity]') required; ` +
                  `buttons outside group remain visible — deceptive panel layout; ` +
                  `textContent="${textEl.textContent.trim().slice(0,30)}..." intact`
          });
          break;
        }
      }
      ancestor = ancestor.parentElement;
    }
  }
  return findings;
}

HIGH — SA-CSS-OPA-003: opacity="0" on a parent <g> group makes all child text elements invisible, while the text elements themselves have no opacity attribute and appear normal when checked in isolation. Detection requires checking all ancestor <g> and <svg> elements for low opacity values, not only the text and tspan elements directly. Use el.closest('[opacity]') or a manual ancestor walk checking getComputedStyle(ancestor).opacity at each level.

Attack 4: JS mousedown sets opacity=0, reverts at mouseup (SA-CSS-OPA-004)

The transient injection form of the element-level opacity attack operates by setting opacity="0" on the consent text element at the precise moment of a mousedown event and removing it at mouseup. At rest, the element has no opacity attribute (defaulting to 1) and renders fully visible with its dark fill. When the user depresses the mouse button over the SVG consent panel, the capture-phase mousedown handler fires before the click event, setting opacity="0" via el.setAttribute('opacity', '0'). The entire text element vanishes during the click window. At mouseup, the attribute is removed and the element returns to full visibility.

A key technical detail distinguishes the SVG opacity presentation attribute form from the CSS opacity property form, and this matters for detection. When the injection uses el.setAttribute('opacity', '0'), it sets the SVG presentation attribute. When the injection uses el.style.opacity = '0', it sets the CSS property via the inline style attribute. Both produce the same rendering result, but they are observed differently by a MutationObserver. An observer watching for attributeName === 'opacity' will catch the presentation attribute form. An observer watching for attributeName === 'style' will catch the inline style form (but must then parse the style string to check whether the opacity property is being set). A robust scanner must watch both attribute forms simultaneously.

The opacity attribute/property interaction with mousedown is particularly difficult to detect through static analysis because the consent panel looks completely normal at inspection time. The malicious code is typically in a script block elsewhere in the page or in a bundled JavaScript file, making source-level detection non-trivial. The interaction-based dynamic scanner approach — simulating mousedown events on the SVG and observing DOM mutations — is the most reliable method. Additionally, a scanner can check whether any event listener is registered on the SVG or its ancestors for mousedown events using getEventListeners (in DevTools context) or by instrumenting addEventListener calls. However, the MutationObserver approach is more robust against obfuscated listener registration.

<!-- SA-CSS-OPA-004: opacity=0 injected at mousedown; reverts at mouseup -->
<!-- At rest: no opacity attr; text visible. During click: opacity=0; invisible. -->
<svg id="consent-svg-opa" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
  <rect width="440" height="120" fill="white"/>

  <!-- At rest: no opacity attr; fill=#111 visible -->
  <text id="scope-text-opa" 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-opa');
  const text = document.getElementById('scope-text-opa');
  // Note: attack may use setAttribute (SVG presentation attr form)
  // OR el.style.opacity='0' (CSS property form via style attr) — or both
  svg.addEventListener('mousedown', () => {
    text.setAttribute('opacity', '0');
    // or: text.style.opacity = '0';
  }, true);
  svg.addEventListener('mouseup', () => {
    text.removeAttribute('opacity');
    // or: text.style.opacity = '';
  }, true);
})();
</script>

// Detection: MutationObserver watching 'opacity' attr + 'style' attr on SVG text/tspan
// Both SVG presentation attribute and CSS property forms must be watched
function watchOpacityInject(svgEl) {
  const findings = [];
  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      const attr = mut.attributeName;
      if (attr !== 'opacity' && attr !== 'style') continue;
      const el = mut.target;
      if (el.tagName !== 'text' && el.tagName !== 'tspan') continue;

      let opacityVal;
      if (attr === 'opacity') {
        opacityVal = parseFloat(el.getAttribute('opacity') ?? '1');
      } else {
        // style attr: parse opacity property from inline style string
        opacityVal = parseFloat(el.style.opacity || '1');
      }

      if (opacityVal < 0.1) {
        findings.push({
          severity: 'CRITICAL', code: 'SA-CSS-OPA-004',
          note: `Dynamic ${attr}="${attr === 'opacity' ? el.getAttribute('opacity') : el.style.opacity}" ` +
                `on <${el.tagName}> during interaction; ` +
                `element-level compositing alpha=0 during click window; ` +
                `fill and stroke values unchanged — only opacity causes invisibility; ` +
                `removed at mouseup; both SVG opacity attr and CSS style.opacity must be watched; ` +
                `textContent="${el.textContent.trim().slice(0,30)}..." intact`
        });
      }
    }
  });
  observer.observe(svgEl, {
    subtree: true, attributes: true, attributeOldValue: true,
    attributeFilter: ['opacity', '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-OPA-004: JavaScript sets opacity="0" at mousedown and removes it at mouseup, making the entire text element invisible during the click window. Static DOM shows no opacity attribute at rest. Detection requires a MutationObserver watching both the opacity SVG presentation attribute and the style attribute (CSS property form: el.style.opacity) on SVG text and tspan elements, combined with synthetic interaction simulation. Both injection forms produce identical rendering but are observed through different attribute mutations.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-OPA-001: opacity=0 on text element — entire compositing alpha zero opacity=0 composites fully-painted element at zero alpha; fill=#111 (dark, contrast passes); getComputedStyle(el).fill returns visible color; only opacity check reveals deception textContent; fill-color contrast check; stroke check; BCR; must check getComputedStyle(el).opacity < 0.1 Critical
SA-CSS-OPA-002: opacity=0.04 — near-zero compositing, fill looks normal 4% element compositing; effective pixel near-white on white; fill paint channel reports opaque dark values; boolean opacity!=0 passes; threshold <0.1 required textContent; boolean opacity!=0 check; fill contrast check; computed opacity threshold <0.1 as float required Critical
SA-CSS-OPA-003: opacity=0 on parent <g> group, text element has no opacity Group opacity=0; all children invisible; text el has opacity=1 (no attr); element-level check passes; ancestor walk required; buttons outside group remain visible textContent; element-level opacity check; ancestor <g> opacity walk or el.closest('[opacity]') required High
SA-CSS-OPA-004: mousedown sets opacity=0, reverts mouseup No opacity attr at rest; mousedown injects opacity=0 (SVG attr or CSS style form); element invisible during click; removed at mouseup; static DOM looks normal Static SVG scan; must watch both 'opacity' attr and 'style' attr with MutationObserver + synthetic mousedown simulation Critical

SkillAudit findings for this attack surface

CRITICAL SA-CSS-OPA-001: opacity=0 on text element — element-level compositing alpha is zero regardless of fill or stroke paint channel values; getComputedStyle(el).fill returns a visible dark color (contrast check passes); getComputedStyle(el).opacity is 0; detection requires checking opacity as a separate property distinct from fill-opacity and stroke-opacity, using getComputedStyle(el).opacity parsed as a float, with threshold < 0.1.
CRITICAL SA-CSS-OPA-002: opacity=0.04 near-zero — 4% compositing makes dark-fill text produce near-white pixels on white background; below perceptual visibility threshold; a boolean opacity!=0 check passes this attack; normalized float threshold opacity < 0.1 from getComputedStyle(el).opacity required; opacity multiplies multiplicatively with fill-opacity and stroke-opacity.
HIGH SA-CSS-OPA-003: opacity=0 on parent <g> group — text elements have no opacity attribute (computed to 1 in isolation); parent group compositing at zero alpha makes all children invisible; buttons outside the group remain visible creating a deceptive panel; detection requires ancestor walk checking opacity on all <g> and <svg> elements, or el.closest('[opacity]') inspection.
CRITICAL SA-CSS-OPA-004: JS mousedown injects opacity=0 on text element; entire rendered element composited at zero alpha during click window; removed at mouseup; static DOM has no opacity attr at rest; MutationObserver must watch both the SVG presentation attribute ('opacity') and the CSS property form ('style' attr containing opacity) simultaneously, with synthetic mousedown simulation to trigger the handler.

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

← Blog  |  Security Checklist