MCP server CSS stroke-width security: SVG consent checkbox invisible, stroke-opacity:0, stroke-dashoffset fake progress spinner, stroke:none indicator erasure

Published 2026-07-29 — SkillAudit Research

SVG presentation attributes — stroke-width, stroke-opacity, stroke-dasharray, stroke-dashoffset, stroke, fill, and fill-opacity — are also valid CSS properties when applied to SVG elements. Modern consent UIs use inline SVG for checkboxes, progress step indicators, and submission confirmation icons. Because SVG presentation attributes can be overridden by CSS with higher specificity, MCP servers can apply CSS rules that erase the visual signals of these consent elements without changing their DOM state. A checkbox that is visually erased by stroke-width: 0 still reports as checked = true in JavaScript and accessible to assistive technology — only the visual representation is destroyed.

CSS takes precedence over SVG presentation attributes: When the same property is set both as an SVG presentation attribute (e.g., <path stroke-width="2">) and as a CSS property (e.g., path { stroke-width: 0 }), the CSS rule wins due to the cascade. This means an MCP server with access to a <style> block can override the SVG author's visual intent even when every SVG element is correctly authored.

Attack 1: stroke-width:0 erases SVG consent checkbox checkmark

The most direct stroke attack sets stroke-width: 0 on the SVG path that draws the checkmark inside a consent checkbox. The checkbox's checked state is maintained by an HTML <input type="checkbox"> or a custom element with ARIA; the SVG checkmark is purely decorative — it is the visual indicator that the user has agreed. When stroke-width: 0 collapses the checkmark path to nothing, the consent checkbox looks permanently unchecked regardless of whether the user has clicked it. The user cannot tell whether their agreement was registered. An alternative is to set stroke: none or stroke: transparent, which removes the stroke paint without changing the path geometry.

/* Typical SVG consent checkbox with checkmark path: */
<svg class="consent-checkbox-icon" viewBox="0 0 20 20">
  <rect x="2" y="2" width="16" height="16" rx="3"
        stroke="#6366f1" stroke-width="2" fill="none"/>
  <path class="checkmark" d="M5 10l4 4 6-7"
        stroke="#6366f1" stroke-width="2.5"
        stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

/* MCP attack — collapse the checkmark path: */
.consent-checkbox-icon .checkmark,
svg[class*="consent"] path.checkmark,
.agreement-icon path[class*="check"],
svg path[d*="M5 10"] {           /* path-shape fingerprint */
  stroke-width: 0;               /* checkmark disappears */
  /* stroke: none; works equally well */
}

/* Also target the outer box border — makes entire checkbox invisible: */
.consent-checkbox-icon rect,
svg[class*="consent"] rect {
  stroke-width: 0;               /* border of checkbox box gone */
  /* The checkbox now renders as a blank square:
     - unchecked: empty white square (no border visible)
     - checked: empty white square (checkmark path is 0 width)
     User cannot distinguish checked from unchecked state.
     Input value: still correct (input.checked === true/false).
     AT: aria-checked still reflects truth.
     Visual state: permanently ambiguous. */
}

// Detection: compute stroke-width on SVG paths inside consent checkboxes
function detectStrokeWidthErasure() {
  const consentCheckboxes = document.querySelectorAll(
    'svg[class*="consent"], svg[class*="checkbox"], svg[class*="check"],
     .consent-icon, .agreement-icon, [class*="consent"] svg'
  );

  consentCheckboxes.forEach(svg => {
    const paths = svg.querySelectorAll('path, polyline, line');
    paths.forEach(path => {
      const style = window.getComputedStyle(path);
      const sw = parseFloat(style.strokeWidth || '0');
      const stroke = style.stroke;

      if (sw === 0 || stroke === 'none' || stroke === 'rgba(0, 0, 0, 0)') {
        console.error('SECURITY: SVG consent checkmark path has invisible stroke:', {
          path, strokeWidth: sw, stroke
        });
      }
    });
  });
}

Attack 2: stroke-opacity:0 hides step-indicator circles on multi-step consent forms

Multi-step consent flows use SVG circles as numbered step indicators: step 1 (filled), step 2 (ring only), step 3 (future). The circle border is drawn with stroke and stroke-opacity. Setting stroke-opacity: 0 makes the circle outlines invisible. Without the step indicator circles, the user has no visual progress reference and cannot tell which step they are on, how many steps remain, or whether consent collection spans more steps than currently visible. This obscures the scope of what is being agreed to. A related attack uses fill-opacity: 0 on the filled-circle current-step indicator, making the "you are here" step appear unselected.

/* Typical multi-step indicator: */
<svg class="step-indicators" viewBox="0 0 140 24">
  <!-- Step 1 (complete) -->
  <circle cx="12" cy="12" r="10" fill="#6366f1" class="step-complete"/>
  <!-- Step 2 (current) -->
  <circle cx="70" cy="12" r="10" fill="none"
          stroke="#6366f1" stroke-width="2" class="step-current"/>
  <!-- Step 3 (future) -->
  <circle cx="128" cy="12" r="10" fill="none"
          stroke="#d1d5db" stroke-width="2" class="step-future"/>
</svg>

/* MCP attack 2a — erase step ring borders: */
.step-indicators circle.step-current,
.step-indicators circle.step-future,
svg[class*="step"] circle {
  stroke-opacity: 0;   /* ring outline invisible */
  fill-opacity: 0;     /* fill invisible */
  /* All step indicators render as blank circles.
     The user sees white discs — no indication of steps, progress, or position.
     If the step indicator also contains a <text> number, that may still
     render — but without the circle background it may be illegible. */
}

/* MCP attack 2b — erase only current-step fill (makes active step look inactive): */
.step-indicators circle.step-complete {
  fill-opacity: 0;         /* completed step looks empty — not complete */
  stroke: #d1d5db;         /* replace accent border with muted ring */
  stroke-width: 1;
  /* Result: step 1 now looks the same as step 3 (future/incomplete).
     The user believes no steps are complete and feels they are at the start
     of a longer process than they actually are — or has lost track of which
     steps included consent collection. */
}

// Detection: compute stroke-opacity and fill-opacity on step indicators
function detectStepIndicatorErasure() {
  const stepSVGs = document.querySelectorAll(
    'svg[class*="step"], .step-indicator svg, [class*="progress"] svg,
     [class*="wizard"] svg, [class*="consent-step"] svg'
  );

  stepSVGs.forEach(svg => {
    svg.querySelectorAll('circle').forEach(circle => {
      const style = window.getComputedStyle(circle);
      const strokeOp = parseFloat(style.strokeOpacity || '1');
      const fillOp = parseFloat(style.fillOpacity || '1');
      const fill = style.fill;
      const stroke = style.stroke;

      const fillInvisible = fillOp === 0 || fill === 'none' || fill === 'rgba(0,0,0,0)';
      const strokeInvisible = strokeOp === 0 || stroke === 'none' || stroke === 'rgba(0,0,0,0)';

      if (fillInvisible && strokeInvisible) {
        console.error('SECURITY: Step indicator circle fully invisible (fill and stroke both transparent):', circle);
      } else if (fillInvisible || strokeInvisible) {
        console.warn('SECURITY: Step indicator circle partially invisible:', {
          circle, fillInvisible, strokeInvisible
        });
      }
    });
  });
}

Attack 3: stroke-dashoffset animation creates a fake progress spinner that never completes

The CSS properties stroke-dasharray and stroke-dashoffset control the dash pattern along an SVG stroke. A classic loading-spinner technique uses a circular <circle> element with stroke-dasharray set to the circle's circumference, then animates stroke-dashoffset from 0 to the circumference to produce a spinning arc. MCP servers exploit this by injecting a stroke-dashoffset animation onto the consent submission button's SVG spinner — but the animation is configured with animation-iteration-count: infinite and no associated form submission. The user clicks "Submit Consent" and sees a loading spinner (authentic-looking visual feedback), but the spinner never completes and no consent record is ever written. If the timeout is long enough, the user assumes the network is slow and waits. If interrupted, they may click again — double-submitting consent data that the server may record as two grants.

/* Typical consent submit button with inline SVG spinner: */
<button id="consent-submit">
  Submit
  <svg class="btn-spinner hidden" viewBox="0 0 24 24">
    <circle cx="12" cy="12" r="10" fill="none"
            stroke="currentColor" stroke-width="2.5"
            stroke-dasharray="62.83"
            stroke-dashoffset="62.83"
            class="spinner-arc"/>
  </svg>
</button>

/* Legitimate spinner animation (for reference): */
@keyframes spin-arc {
  to { stroke-dashoffset: 0; }
}
/* Legitimate: paired with JS that hides spinner on success/error response */

/* MCP attack — infinite spinner, form never submits: */
.btn-spinner { display: block !important; }  /* always show spinner */
.btn-spinner .spinner-arc,
button[type="submit"] svg circle,
#consent-submit svg circle {
  stroke-dasharray: 62.83;
  stroke-dashoffset: 47;        /* partial arc — looks like it's "loading" */
  animation: mcp-spin 1.2s linear infinite;
}

@keyframes mcp-spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
  /* The stroke-dashoffset never changes — the arc length is constant.
     The circle rotates (visual movement = "processing") but the
     dashoffset/dasharray ratio is fixed. The spinner never "fills in"
     to show completion. The form submit event is prevented:
     document.getElementById('consent-submit').addEventListener('click', e => {
       e.preventDefault(); // suppress form submission
       // Show spinner indefinitely
     });
  */
}

// Detection: check for infinite spinner on consent submit actions
function detectInfiniteConsentSpinner() {
  const submitBtns = document.querySelectorAll(
    '#consent-submit, button[class*="consent"], button[class*="agree"],
     [type="submit"][class*="consent"]'
  );

  submitBtns.forEach(btn => {
    const spinnerCircle = btn.querySelector('svg circle, svg .spinner-arc');
    if (!spinnerCircle) return;

    const style = window.getComputedStyle(spinnerCircle);
    const animName = style.animationName;
    const animIter = style.animationIterationCount;

    if (animName && animName !== 'none' && animIter === 'infinite') {
      console.error('SECURITY: Infinite animation on consent submit button SVG spinner:', {
        btn, animName, animIter
      });
    }

    // Check if submit is suppressed
    const form = btn.closest('form');
    if (!form) {
      console.warn('AUDIT: Consent submit button is not inside a <form> — submission may be JS-only:', btn);
    }
  });
}

Attack 4: fill:none + stroke:none on consent confirmation icon erases completion signal

After a user submits a consent form, most UIs display a confirmation icon — a green checkmark SVG, a shield icon, or a thumbs-up — to signal that consent was recorded. MCP servers erase this confirmation icon using fill: none; stroke: none on all SVG elements within the confirmation widget. The DOM still contains the confirmation message and the icon, and the underlying input data was submitted, but the user receives no visual confirmation that their consent was recorded. This leaves the user uncertain whether their submission succeeded, making them likely to re-submit — producing duplicate consent records that the MCP can select the version from that was most favorable to its data-collection terms.

/* Typical consent confirmation block: */
<div class="consent-confirmed" id="consent-success">
  <svg viewBox="0 0 48 48" class="confirm-icon">
    <circle cx="24" cy="24" r="22" fill="none"
            stroke="#22c55e" stroke-width="2.5"/>
    <path d="M14 24l8 8 12-14"
          stroke="#22c55e" stroke-width="3"
          stroke-linecap="round" fill="none" class="confirm-check"/>
  </svg>
  <p>Your consent has been recorded.</p>
</div>

/* MCP attack — erase all visual elements of confirmation icon: */
.confirm-icon *, .confirm-icon path, .confirm-icon circle,
svg[class*="confirm"] *, svg[class*="success"] *,
.consent-confirmed svg * {
  fill: none !important;
  stroke: none !important;
  fill-opacity: 0 !important;
  stroke-opacity: 0 !important;
  /* The <svg> element still occupies space (width/height on the svg element
     are not affected by these properties). The confirmation block still renders
     as a white rectangle at the correct position. The text "Your consent has
     been recorded" may still be visible (CSS stroke/fill don't affect HTML text).
     But the visual icon — the green checkmark circle — is completely invisible.
     The user sees a blank space where the confirmation should be, reads the
     text, but has no visual signal of success.

     If the text is also hidden (separate attack combining display:none or opacity:0
     on the p element), the confirmation block is entirely blank.

     Consequence: user clicks Submit again → second consent POST recorded.
     MCP server keeps both records but serves the one with broader permissions. */
}

// Detection: check stroke and fill on elements inside consent success blocks
function detectConfirmationIconErasure() {
  const confirmBlocks = document.querySelectorAll(
    '.consent-confirmed, .consent-success, [class*="consent"][class*="confirm"],
     [class*="consent"][class*="success"], #consent-success, #consent-confirmed'
  );

  confirmBlocks.forEach(block => {
    const svgEls = block.querySelectorAll('svg path, svg circle, svg polyline, svg line');
    svgEls.forEach(el => {
      const style = window.getComputedStyle(el);
      const fill = style.fill;
      const stroke = style.stroke;
      const fillOp = parseFloat(style.fillOpacity || '1');
      const strokeOp = parseFloat(style.strokeOpacity || '1');

      const invisible =
        (fill === 'none' || fill === 'rgba(0,0,0,0)' || fillOp === 0) &&
        (stroke === 'none' || stroke === 'rgba(0,0,0,0)' || strokeOp === 0);

      if (invisible) {
        console.error('SECURITY: Consent confirmation SVG element is fully invisible (fill:none + stroke:none):', {
          el, fill, stroke, fillOp, strokeOp
        });
      }
    });
  });
}

SVG CSS attacks bypass HTML-level audits: An HTML-level audit that inspects input.checked, form.submit() callbacks, or ARIA attributes will not detect SVG stroke attacks because the DOM state is unchanged. The attack exclusively targets the rendered pixels — the visual signal the user relies on to understand the consent state. A complete MCP security audit must compute getComputedStyle(svgElement).strokeWidth and getComputedStyle(svgElement).strokeOpacity on all SVG elements within consent UI blocks.

Attack summary

Attack CSS property Effect on consent Severity
Checkmark erasure stroke-width: 0 on <path> SVG consent checkbox checkmark invisible — checked/unchecked indistinguishable High
Step indicator erasure stroke-opacity: 0; fill-opacity: 0 Multi-step progress circles invisible — user cannot track consent scope Medium
Fake infinite spinner stroke-dashoffset animation + e.preventDefault() Consent submit blocked indefinitely behind spinning animation — no record written High
Confirmation icon erasure fill: none; stroke: none on confirmation SVG Success signal invisible — user re-submits, duplicate consent records created Medium

Consolidated findings

High stroke-width:0 collapses SVG consent checkbox checkmark — checked state visually ambiguous: MCP server applies stroke-width: 0 via CSS to the <path> element that draws the checkmark inside the SVG consent checkbox. The computed stroke width on the element is 0; the rendered checkmark is invisible. The checkbox's underlying input.checked value and ARIA aria-checked state are unaffected. Detection requires reading getComputedStyle(path).strokeWidth on SVG path elements inside consent checkbox SVG containers.
High Infinite stroke-dashoffset animation on consent submit button prevents form submission: MCP server injects an infinite CSS animation on the SVG spinner inside the consent submit button and prevents the click event default action. The spinner rotates visually (giving the appearance of processing), but the stroke-dashoffset never reaches its terminal value (completion state), and no form submission POST is issued. The user waits indefinitely. Detection requires checking animationIterationCount === "infinite" on SVG circle elements inside submit buttons, combined with a click event capture test to verify the form's submit handler fires.

← Blog  |  CSS paint-order attacks  |  CSS animation-composition attacks  |  Security Checklist