MCP server SVG mask element security

The SVG <mask> element is a luminance mask: white areas in the mask reveal the masked content, black areas hide it. This counterintuitive behavior — black means invisible, not a decorative outline — is the primary attack surface. An auditor that checks whether a mask element is "present" without checking the mask's fill colors will miss black-fill attacks. The mask element is structurally correct, the content element has a positive bounding rect, and all standard visibility properties read as present.

SVG mask luminance semantics

Mask fill colorLuminance valueEffect on masked content
white / #fff1.0Full opacity — content fully visible
gray / #888~0.550% opacity — content semi-transparent
black / #0000.0Zero opacity — content fully hidden
transparent (alpha=0)0.0 (alpha composite)Zero opacity — same effect as black for alpha-mode masks

Contrast with clipPath: SVG clipPath works by path inclusion — inside the path area is visible, outside is invisible. SVG mask works by luminance (or alpha) — white reveals, black hides. The attack vectors are structurally similar (both require following a URL reference from the element's attribute) but the polarity is reversed: a white-filled clipPath is safe; a white-filled mask is also safe. A black-filled clipPath is an attack; a black-filled mask is also an attack. The difference is that clipPath uses geometric containment; mask uses color luminance.

Finding SA-MASK-001: black-filled mask rectangle hides entire consent element

CriticalA <mask> element containing a full-coverage rectangle with fill="black" masks the consent text element to zero opacity. The mask is present and the element has correct DOM properties — the attack is the counterintuitive black=hide semantics. Static auditors that check for mask presence without checking fill color will conclude the mask is decorative and the content is present.
<defs>
  <mask id="black-mask">
    <!-- Black = luminance 0 = full hide -->
    <rect x="0" y="0" width="100%" height="100%" fill="black"/>
  </mask>
</defs>

<text mask="url(#black-mask)">
  I authorize read-write access to my file system.
</text>

Finding SA-MASK-002: CSS custom property controls mask fill color

CriticalThe mask rectangle's fill is set via a CSS custom property that defaults to white (safe initial value): fill: var(--mask-fill, white). The MCP server's stylesheet sets --mask-fill: black in a rule that overrides the default. At parse time, an auditor that reads the initial CSS custom property value finds white — safe. In the rendered page with the MCP server's stylesheet loaded, getComputedStyle(maskRect).fill returns black — hidden. The attack depends on stylesheet loading order.
<defs>
  <mask id="var-mask">
    <rect x="0" y="0" width="100%" height="100%"
          style="fill: var(--mask-fill, white)"/>
  </mask>
</defs>

<style>
/* MCP server stylesheet (loaded after consent form definition): */
:root { --mask-fill: black; }
</style>

Why static analysis misses CSS variable attacks: Static analysis of the SVG source finds var(--mask-fill, white) and resolves the fallback to white — safe. The attack is in the cascade: the MCP server's stylesheet defines --mask-fill: black at root scope. Only calling getComputedStyle(maskRectEl).fill after all stylesheets are loaded reveals the computed black value. This is a cross-file attack where the SVG source and the CSS source are each individually benign.

Finding SA-MASK-003: maskUnits=userSpaceOnUse with scaled ancestor

HighWith maskUnits="userSpaceOnUse", the mask element's x, y, width, and height are in the coordinate system of the element that references the mask. If the consent element is inside a scaled ancestor, the mask's user-space coverage area is scaled with it. A mask with width="300" height="100" in user-space, applied to an element whose ancestor has transform="scale(0.001)", covers only 0.3 × 0.1 screen pixels — effectively no coverage. The consent text falls outside the (near-zero) mask coverage area and receives zero opacity from the black default background of the mask element.
<defs>
  <mask id="scaled-mask" maskUnits="userSpaceOnUse"
        x="0" y="0" width="300" height="100">
    <rect x="0" y="0" width="300" height="100" fill="white"/>
  </mask>
</defs>

<g transform="scale(0.001)">
  <!-- Mask coverage in screen pixels: 0.3×0.1 — effectively nothing -->
  <text mask="url(#scaled-mask)">Consent text...</text>
</g>

Finding SA-MASK-004: SMIL animate moves white mask region off consent text at interaction

HighA mask containing a white-filled rectangle in the correct position to reveal the consent text uses a SMIL animate element to reposition the white rectangle outside the mask bounds when the submit button is clicked. After the animation, the white rectangle is at a position that does not overlap the consent text — the consent text falls in the black (zero luminance) default area of the mask and is hidden. The transition from visible to hidden happens at the interaction moment.
<defs>
  <mask id="animated-mask">
    <rect fill="white" x="0" y="0" width="100%" height="100%">
      <!-- Animate moves white rect to off-screen position at click -->
      <animate attributeName="x" to="200%"
               begin="submitBtn.click" dur="0.05s" fill="freeze"/>
    </rect>
  </mask>
</defs>

<text mask="url(#animated-mask)">Consent text...</text>

Detection: checking mask fill luminance and animation

function checkSVGMasks(consentEl) {
  const risks = [];
  const maskRef = consentEl.getAttribute('mask')
               || getComputedStyle(consentEl).mask;

  if (!maskRef || maskRef === 'none') return risks;

  const idMatch = maskRef.match(/url\(#([^)]+)\)/);
  if (!idMatch) return risks;

  const maskEl = document.getElementById(idMatch[1]);
  if (!maskEl) return risks;

  // Check maskUnits for coordinate scaling attack
  const units = maskEl.getAttribute('maskUnits') || 'objectBoundingBox';
  if (units === 'userSpaceOnUse') {
    let ancestor = consentEl.parentElement;
    while (ancestor && ancestor !== document.body) {
      const t = ancestor.getAttribute('transform') || '';
      if (/scale\s*\(\s*0\.0/.test(t)) {
        risks.push({ finding: 'SA-MASK-003', severity: 'high',
          note: 'maskUnits=userSpaceOnUse with scaled ancestor — mask coverage near zero' });
      }
      ancestor = ancestor.parentElement;
    }
  }

  // Check fill luminance of mask shapes
  const maskShapes = maskEl.querySelectorAll('rect, circle, ellipse, path, polygon');
  for (const shape of maskShapes) {
    const computedFill = getComputedStyle(shape).fill || shape.getAttribute('fill') || '';
    if (isDarkColor(computedFill)) {
      risks.push({ finding: 'SA-MASK-001', severity: 'critical',
        note: 'Mask shape has dark/black fill — hides masked content via luminance=0' });
    }

    // Check for SMIL animation (SA-MASK-004)
    const animEls = shape.querySelectorAll('animate, set, animateTransform');
    for (const anim of animEls) {
      const begin = anim.getAttribute('begin') || '';
      if (/click|mousedown|mouseup|focus|mouseenter/.test(begin)) {
        risks.push({ finding: 'SA-MASK-004', severity: 'high',
          note: 'Mask shape position animated at interaction — may move white area off consent text' });
      }
    }
  }

  return risks;
}

function isDarkColor(colorStr) {
  if (/^black$|^#000/i.test(colorStr)) return true;
  const m = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
  if (!m) return false;
  const luminance = 0.2126 * m[1]/255 + 0.7152 * m[2]/255 + 0.0722 * m[3]/255;
  return luminance < 0.15;
}

Remediation

ControlHow it helps
For any mask applied to a consent element, compute the luminance of all mask shapes via getComputedStyle (not getAttribute — to catch CSS variable overrides); flag any shape with luminance < 0.5 that covers the consent text region SA-MASK-001 and SA-MASK-002 require checking computed luminance, not declared fill color. CSS custom properties can override the declared value to black after the SVG source declares white. Only getComputedStyle after full stylesheet loading reveals the actual rendered fill.
Treat any animate, set, or animateTransform child of a mask shape that references interaction events as a timing attack; evaluate the shape position after the animation using the to value SA-MASK-004 combines the mask luminance attack surface with SMIL timing. The white mask rectangle is in position at load time but repositioned at interaction. This is the same detection gap as other SMIL timing attacks: only animate child enumeration with begin= inspection reveals the attack.
Do not apply SVG mask attributes to consent text elements; any mask present on a consent element is a high-confidence manipulation signal given the absence of legitimate consent-form use cases for luminance masking Preventive control: SVG luminance masks have no legitimate role in styling consent text elements. An unconditional flag on any mask= attribute on a consent element catches all four vectors without requiring geometry analysis.

SkillAudit resolves SVG mask references, checks mask shape fill luminance via computed styles (catching CSS custom property overrides), detects SMIL animation on mask shapes, and evaluates maskUnits coordinate scaling attacks. Run a free audit on your MCP server GitHub URL.