← Blog

MCP server CSS paint-order SVG text stroke consent security

Four SA-CSS-PNO consent attack patterns that exploit SVG text rendering properties to hide consent language from users while evading the CSS color property checks that most consent auditors rely on. SVG text colour is controlled by fill and stroke attributes — not the CSS color property — and paint-order controls which is rendered first. Auditors that only inspect getComputedStyle().color will find correct values on SVG text elements that are rendered entirely in white, or that have glyph geometry obliterated by stroke-width manipulation.

By SkillAudit  |  31 Oct 2026

HTML text visibility is primarily controlled by the color CSS property, which getComputedStyle() reliably reports. SVG text visibility is controlled by the fill and stroke presentation attributes, which can be set via CSS, inline attributes, or both. The paint-order CSS property (SVG 2 / CSS Fill and Stroke Level 3) controls the rendering order of fill, stroke, and markers on SVG graphics. By default, browsers render SVG text as fill-first then stroke — the stroke is painted on top of the fill. With paint-order: stroke fill, the order reverses: stroke is painted first (underneath), then fill is painted on top. This reversal enables a class of attacks where an extremely wide stroke, painted first, overwhelms the glyph geometry; the fill painted on top is too narrow to restore legibility. These attacks are specific to SVG-rendered text in MCP server consent UIs and are invisible to auditors that test only HTML text elements.

SA-CSS-PNO-001: paint-order:stroke fill + stroke-width:20px + stroke:white

With the default paint order (fill then stroke), a 20px white stroke would be rendered on top of the dark fill, creating a thick white outline around each glyph that would be obviously visible as a styling anomaly. With paint-order: stroke fill, the 20px white stroke is rendered first — it extends 10px outward and 10px inward from the glyph edge. Then the dark fill is painted on top. The dark fill covers the 10px inward portion of the stroke, but the 10px outward portion remains visible as a thick white halo extending beyond the glyph boundary. For thin characters such as lowercase “i”, “l”, “t”, or thin-weight numerals at body text size (14px), the glyph body is only 1–3px wide. A 10px outward white stroke extension completely overpaints adjacent glyphs and the inter-character space, making the entire word illegible at any font-weight below 600. On a white background, the white stroke halo blends with the background, rendering thin text effectively invisible.

<!-- SVG consent text in MCP server UI -->
<svg viewBox="0 0 600 40" xmlns="http://www.w3.org/2000/svg">
  <text
    x="10" y="28"
    font-size="14"
    font-weight="300"
    fill="black"
    stroke="white"
    stroke-width="20"
    style="paint-order: stroke fill"
  >
    By clicking Allow you authorize permanent file access.
  </text>
</svg>

/* Rendering pipeline:
   Step 1 (stroke first, paint-order:stroke fill):
     20px white stroke drawn → extends 10px outside AND 10px inside glyph edge
     Inward portion: overlaps fill area (will be covered by fill in step 2)
     Outward portion: 10px white band around every glyph — covers adjacent glyphs

   Step 2 (fill second):
     Black fill drawn on top of stroke in the fill area (interior of glyph)
     Result: glyph interior is black; halo is white
     BUT: glyph interior at font-weight:300, 14px is ~1-2px wide for thin strokes
          A 10px white halo overwhelms the entire glyph at this scale

   Visual result on white background (#fff):
     Glyph interior: thin black line (~1px for 'i' at font-size:14 weight:300)
     Glyph halo: 10px white band → merges with white background
     → Text appears as faint thin black marks amid white, effectively illegible
     → On white background: glyphs nearly invisible

   getComputedStyle(textEl):
     fill: 'black' → auditor passes colour check → MISSED
     color: unset or 'inherit' → auditor checks color → NOT SVG fill → MISSED
     stroke: 'white' (suspicious but requires SVG-aware audit)
     strokeWidth: '20' → suspicious
     paintOrder: 'stroke fill' → suspicious if auditor checks SVG properties */

/* ---- Detection algorithm ---- */
function detectPNO001(svgTextEl) {
  const cs = getComputedStyle(svgTextEl);

  /* Check paint-order */
  const paintOrder = cs.paintOrder || svgTextEl.getAttribute('style')?.match(/paint-order\s*:\s*([^;]+)/)?.[1];
  if (!paintOrder || !paintOrder.includes('stroke') || paintOrder.indexOf('stroke') >= paintOrder.indexOf('fill')) {
    return null; /* default fill-first order — not this attack */
  }

  /* Check stroke width */
  const strokeWidth = parseFloat(
    cs.strokeWidth ||
    svgTextEl.getAttribute('stroke-width') || '1'
  );
  if (strokeWidth < 8) return null; /* thin stroke: not overwhelming */

  /* Check stroke colour — is it close to the background? */
  const stroke = cs.stroke || svgTextEl.getAttribute('stroke') || 'none';
  const bg = getComputedStyle(svgTextEl.closest('svg') || document.body).backgroundColor;

  /* Parse colour to RGB for proximity check */
  function isNearWhite(colorStr) {
    const m = colorStr.match(/rgb[a]?\((\d+),\s*(\d+),\s*(\d+)/);
    if (!m) return false;
    return parseInt(m[1]) > 220 && parseInt(m[2]) > 220 && parseInt(m[3]) > 220;
  }

  if (isNearWhite(stroke)) {
    reportFinding('SA-CSS-PNO-001', 'CRITICAL',
      `paint-order:stroke fill + stroke-width:${strokeWidth}px + white stroke: ` +
      `glyph geometry overwhelmed by outward stroke halo`);
  }
}

Audit blind spot — SVG vs HTML text: The overwhelming majority of CSS consent auditors check getComputedStyle(el).color for text visibility. This check is entirely correct for HTML text elements (<p>, <span>, <div>). For SVG <text> elements, the relevant properties are fill, stroke, stroke-width, and paint-order. An auditor that only checks color on an SVG text element will always report a false negative — the CSS color property either inherits (and is unrelated to rendering) or is simply not set, while the actual rendering is controlled by presentation attributes.

CRITICAL
SA-CSS-PNO-001 — paint-order: stroke fill + stroke-width: 20px + stroke: white on SVG text: outward white stroke halo overwhelms thin glyphs at body text sizes; text effectively invisible on white background; getComputedStyle().color check returns correct value. Detection: SVG text with paint-order stroke-first + stroke-width > 8px + near-white stroke colour → CRITICAL.

SA-CSS-PNO-002: stroke:rgba(255,255,255,0.99) near-opaque white halo on thin fonts

A 1px stroke with near-opaque white colour (rgba(255, 255, 255, 0.99)) applied to SVG text creates a sub-pixel white halo around every anti-aliased glyph edge. At normal reading sizes (12–16px), font rendering engines use anti-aliasing to smooth glyph edges by blending foreground and background colours across edge pixels. A white stroke at 1px width with opacity 0.99 is rendered at every anti-aliased edge pixel, overriding the anti-aliasing blend and replacing it with a near-white pixel. For thin-weight fonts (font-weight: 100 or 200), where glyph strokes are only 1–2px wide, this near-white pixel halo can account for 50–100% of the visible glyph pixels, reducing the effective contrast to near-zero. The attack is subtle because rgba(255, 255, 255, 0.99) appears virtually identical to plain white in a hex colour picker or developer tools colour swatch — auditors using exact-match comparisons against literal white or #fff or rgb(255, 255, 255) will miss the rgba variant.

<svg viewBox="0 0 600 20">
  <text
    x="5" y="15"
    font-size="13"
    font-weight="100"
    fill="#111"
    stroke="rgba(255,255,255,0.99)"
    stroke-width="1"
    /* paint-order: default (fill then stroke) — stroke paints on top of fill */
  >By clicking Allow you authorize permanent file access.</text>
</svg>

/* Why 0.99 alpha instead of 1.0 (fully opaque):
   - rgba(255,255,255,1.0) === rgb(255,255,255) === white
   - An auditor checking: stroke === 'rgb(255, 255, 255)' WOULD catch plain white
   - rgba(255,255,255,0.99) is NOT equal to 'rgb(255, 255, 255)' in string comparison
   - String-based auditors miss the 0.99 variant

   Rendering physics:
   At 13px font-weight:100, a typical glyph stroke is ~1.2px wide.
   Anti-aliasing distributes ink across ~2-3 pixels at glyph edges.
   A 1px near-white stroke:
     - Paints on top of the fill (default paint-order)
     - Covers both edge pixels of the glyph at each anti-aliased edge
     - Results in: edge pixels → near-white blend (~5% dark, ~95% white)
     - Central glyph pixel (~1px): retains dark fill
     - Visual effect: glyph appears as barely-there 1px dark trace surrounded by white
     - At small sizes or on high-DPI: effectively illegible */

/* ---- Detection algorithm ---- */
function detectPNO002(svgTextEl) {
  const cs = getComputedStyle(svgTextEl);
  const strokeAttr = svgTextEl.getAttribute('stroke') ||
                     cs.stroke || 'none';

  /* Parse rgba(r,g,b,a) stroke */
  const rgba = strokeAttr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
  if (!rgba) return null;

  const [, r, g, b, a = '1'] = rgba;
  const alpha = parseFloat(a);
  const luminance = (parseInt(r) * 0.299 + parseInt(g) * 0.587 + parseInt(b) * 0.114) / 255;

  /* Detect: near-opaque (alpha > 0.9) AND near-white (luminance > 0.9) */
  if (alpha > 0.9 && luminance > 0.9) {
    const strokeWidth = parseFloat(
      cs.strokeWidth || svgTextEl.getAttribute('stroke-width') || '1'
    );
    const fontWeight = parseInt(cs.fontWeight || svgTextEl.getAttribute('font-weight') || '400');

    /* Higher severity when combined with thin font-weight */
    const severity = fontWeight <= 200 ? 'MEDIUM' : 'LOW';
    reportFinding('SA-CSS-PNO-002', severity,
      `SVG text stroke:rgba(${r},${g},${b},${alpha}) — near-opaque white halo, ` +
      `font-weight:${fontWeight}, stroke-width:${strokeWidth}px`);
  }
}

High-DPI rendering amplifies the attack: On Retina and high-DPI displays (2x, 3x pixel density), font rendering engines perform sub-pixel anti-aliasing at higher resolution before downsampling. A 1px white stroke at 2x DPI resolves to 2 physical pixels, covering more of the glyph edge. Combined with font-weight: 100 at 13px, virtually no physical pixels of the dark fill remain visible after the near-opaque white stroke is composited. The attack is more severe on the premium hardware commonly used by developers and power users who are the primary audience for MCP server consent dialogs.

MEDIUM
SA-CSS-PNO-002 — stroke: rgba(255,255,255,0.99) near-opaque white on SVG text with thin font-weight: white halo at glyph edges reduces effective contrast to near-zero at body text sizes; evades exact-match white / #fff comparisons. Detection: SVG text stroke parsed to luminance > 0.9 + alpha > 0.9 → MEDIUM (escalates if font-weight ≤ 200).

SA-CSS-PNO-003: stroke:white + fill:white — SVG white-on-white evading HTML color auditors

HTML text made white-on-white is trivially detected by any auditor checking getComputedStyle(el).color === 'rgb(255, 255, 255)'. SVG text made white-on-white requires setting the SVG fill attribute (not the CSS color property) to white. Many consent auditors — particularly those designed for HTML-first consent UIs and extended to also scan SVG text — check the CSS color property but not the SVG fill presentation attribute. An SVG <text> element with fill="white" and stroke="white" has both its fill colour and its outline colour set to white. On a white background this renders as completely invisible text. The CSS color property on the element, if checked, returns whatever the document inherits — typically black or the body text colour — because SVG presentation attributes override CSS color for SVG rendering but do not change the reported color computed value.

<!-- SVG consent text: white fill + white stroke → invisible on white background -->
<svg viewBox="0 0 600 30" style="background:white">
  <text
    x="10" y="22"
    font-size="15"
    font-weight="400"
    fill="white"        <!-- glyph interior: white -->
    stroke="white"      <!-- glyph outline: white -->
    stroke-width="0.5"  <!-- small stroke to maintain glyph dimensions -->
  >
    By clicking Allow you authorize permanent file access.
  </text>
</svg>

/* Audit failure scenario:
   Auditor code: getComputedStyle(textEl).color → returns 'rgb(0, 0, 0)' (inherited)
   Auditor sees: 'rgb(0, 0, 0)' — black — passes colour contrast check → MISSED

   Why getComputedStyle().color is wrong for SVG text:
   SVG presentation attributes (fill, stroke) are part of the SVG styling model.
   They are SEPARATE from the CSS color property.
   For SVG  elements:
     - CSS color property: controls text cursor, outlines in some browsers
       BUT does NOT control the rendered fill colour
     - SVG fill attribute: controls rendered glyph fill colour
   These are independent properties that can differ:
     CSS color: black → auditor checks this → passes
     SVG fill: white → actual rendering → invisible

   The disconnect is documented in SVG 1.1 §11.2 and CSS Fill and Stroke Level 3:
   "The fill property paints the interior of the given graphical element.
    [It] is NOT equivalent to the color property." */

/* ---- Detection algorithm ---- */
function detectPNO003(svgEl) {
  const textElements = svgEl.querySelectorAll('text, tspan, textPath');

  for (const textEl of textElements) {
    /* Check SVG fill attribute AND CSS fill property */
    const fillAttr = textEl.getAttribute('fill');
    const cs = getComputedStyle(textEl);
    const fillStyle = cs.fill; /* may be 'rgb(...)' or 'none' or fill attribute value */

    /* Parse fill colour — could be attribute or computed */
    const fill = fillStyle || fillAttr || 'black';
    const stroke = cs.stroke || textEl.getAttribute('stroke') || 'none';

    /* Get parent background colour */
    const parentBg = getComputedStyle(textEl.closest('[style*="background"]') || document.body)
                       .backgroundColor;

    function colorsMatch(a, b) {
      /* Normalize to rgb() for comparison */
      const normalize = s => {
        if (s === 'white' || s === '#fff' || s === '#ffffff') return 'rgb(255, 255, 255)';
        if (s === 'black' || s === '#000' || s === '#000000') return 'rgb(0, 0, 0)';
        return s;
      };
      return normalize(a) === normalize(b);
    }

    if (colorsMatch(fill, parentBg) || colorsMatch(fill, 'white')) {
      reportFinding('SA-CSS-PNO-003', 'CRITICAL',
        `SVG text fill:${fill} matches background or is white — ` +
        `CSS color property check returns inherited value, not fill`);
    }

    /* Also check stroke if it matches background */
    if (stroke !== 'none' && (colorsMatch(stroke, parentBg) || colorsMatch(stroke, 'white'))) {
      if (colorsMatch(fill, 'white') || colorsMatch(fill, parentBg)) {
        reportFinding('SA-CSS-PNO-003', 'CRITICAL',
          `SVG text fill AND stroke both match background: fully invisible glyph rendering`);
      }
    }
  }
}

The SVG fill / CSS color disconnect is a systemic audit gap: This is not an edge case — it is an architectural difference between SVG and HTML text rendering. Every consent auditor that checks only getComputedStyle().color has a blind spot for SVG text elements that use presentation attributes for fill colour. MCP server UIs increasingly use SVG for icon-adjacent text, brand-styled consent dialogs, and canvas-rendered consent overlays. SkillAudit explicitly audits SVG fill, stroke, and opacity presentation attributes in addition to the CSS color property on all text-containing SVG elements.

CRITICAL
SA-CSS-PNO-003 — SVG fill="white" and stroke="white" on consent text: glyph rendering entirely white on white background; HTML consent auditors checking only getComputedStyle().color return an inherited dark colour and report a false negative. Detection: SVG text element with fill attribute or computed fill matching background or white → CRITICAL.

SA-CSS-PNO-004: stroke-dasharray:0,9999 + fill:transparent — invisible stroke, invisible fill

stroke-dasharray defines the pattern of dashes and gaps in an SVG stroke. A stroke-dasharray: 0, 9999 value specifies dashes of length 0px followed by gaps of length 9999px. The result is that no dash is ever rendered — the dash is zero length, so each “drawn segment” of the stroke is invisible. Combined with fill: transparent (or fill: none), the glyph interior is also invisible. The SVG text element occupies its layout space — getBoundingClientRect() returns correct dimensions — and getComputedStyle().stroke returns the stroke colour (e.g., rgb(0, 0, 0)) which appears normal to an auditor checking stroke colour. The zero-length dash pattern defeats stroke-based text rendering entirely while reporting a non-suspicious stroke colour value. This attack pattern is typically used to defeat auditors that specifically check for the SA-CSS-PNO-003 white-fill pattern: by using a non-white stroke colour with a zero dasharray, the attacker avoids both the fill-colour check and the stroke-colour check.

<svg viewBox="0 0 600 30">
  <text
    x="10" y="22"
    font-size="15"
    fill="transparent"         <!-- no fill: glyph interior invisible -->
    stroke="black"             <!-- stroke colour: appears normal in getComputedStyle -->
    stroke-width="1"           <!-- stroke width: appears normal -->
    stroke-dasharray="0,9999"  <!-- zero-length dashes: NO stroke pixels rendered -->
  >
    By clicking Allow you authorize permanent file access.
  </text>
</svg>

/* Rendering result:
   fill: transparent → glyph interior: 0 pixels
   stroke: black, stroke-width:1 → would render 1px outline IF stroke-dasharray weren't 0,9999
   stroke-dasharray: 0 (dash length), 9999 (gap length) → zero-length dashes → no stroke pixels
   Net result: glyph renders zero visible pixels — text is completely invisible

   What getComputedStyle reports:
   fill: 'rgba(0, 0, 0, 0)' (transparent) → SUSPICIOUS: auditor might catch this
   stroke: 'rgb(0, 0, 0)' (black) → NOT suspicious: auditor may PASS stroke check
   strokeWidth: '1' → NOT suspicious
   strokeDasharray: '0, 9999' → SUSPICIOUS but rarely checked

   Why this evades auditors:
   Most SVG text auditors that DO check fill transparency will also check stroke colour
   as a fallback: "if fill is transparent, is stroke a visible colour?"
   → stroke:'rgb(0,0,0)' is visible black → auditor thinks text is visible via stroke
   → BUT stroke-dasharray:0,9999 renders zero stroke pixels
   → False negative: auditor reports text as visible via stroke */

/* ---- Detection algorithm ---- */
function detectPNO004(svgTextEl) {
  const cs = getComputedStyle(svgTextEl);
  const fillAttr = svgTextEl.getAttribute('fill');
  const fill = cs.fill || fillAttr || 'black';

  /* Check for transparent/none fill */
  const fillIsInvisible = (
    fill === 'none' ||
    fill === 'transparent' ||
    fill === 'rgba(0, 0, 0, 0)' ||
    fill.includes('rgba') && fill.match(/,\s*0\s*\)/)
  );

  if (!fillIsInvisible) return null;

  /* Check stroke-dasharray for zero-length dash pattern */
  const dasharrayAttr = svgTextEl.getAttribute('stroke-dasharray') ||
                        cs.strokeDasharray || '';

  if (!dasharrayAttr || dasharrayAttr === 'none') return null;

  /* Parse dasharray — could be "0, 9999" or "0 9999" or "0" */
  const parts = dasharrayAttr.split(/[\s,]+/).map(Number).filter(n => !isNaN(n));
  if (parts.length > 0 && parts[0] === 0) {
    /* First value is dash length: 0 → no dashes rendered */
    reportFinding('SA-CSS-PNO-004', 'HIGH',
      `SVG text fill:transparent + stroke-dasharray:${dasharrayAttr}: ` +
      `zero-length dashes render no stroke pixels; text fully invisible; ` +
      `stroke colour check returns non-suspicious value`);
  }
}

Dasharray normalisation in audits: The stroke-dasharray attribute value is not normalised by getComputedStyle() in all browsers — some browsers return the raw attribute string while others return a computed representation. An audit checking this property should parse the first numerical value of the dasharray pattern (after splitting on whitespace and commas) and flag any value of zero as rendering no stroke pixels, regardless of what the stroke colour property reports. The combination of fill: transparent and stroke-dasharray starting with 0 is always a finding of at least HIGH severity.

HIGH
SA-CSS-PNO-004 — stroke-dasharray: 0, 9999 + fill: transparent on SVG text: zero-length dash pattern renders no stroke pixels; fill is transparent; text completely invisible; getComputedStyle().stroke returns a visually normal colour value. Detection: SVG text with transparent fill + dasharray first-value of 0 → HIGH.

Summary of SA-CSS-PNO findings

IDSeverityKey propertiesAttack mechanismDetection
SA-CSS-PNO-001 CRITICAL paint-order:stroke fill + stroke-width:20px + stroke:white Wide white stroke painted first overwhelms thin glyph geometry; dark fill on top too narrow to restore legibility SVG text with paint-order stroke-first + stroke-width > 8px + near-white stroke colour
SA-CSS-PNO-002 MEDIUM stroke:rgba(255,255,255,0.99) + font-weight:100 Near-opaque white halo at anti-aliased glyph edges reduces effective contrast to near-zero at thin weights SVG text stroke parsed to luminance > 0.9 + alpha > 0.9; severity escalates with font-weight ≤ 200
SA-CSS-PNO-003 CRITICAL fill:white + stroke:white on SVG text SVG presentation attributes control rendering; CSS color property check returns inherited dark value; white-on-white invisible SVG text fill attribute or computed fill value matching background colour; does NOT rely on CSS color property
SA-CSS-PNO-004 HIGH stroke-dasharray:0,9999 + fill:transparent Zero-length dash pattern renders no stroke pixels; transparent fill renders no fill pixels; stroke colour check returns normal value SVG text with fill:transparent + stroke-dasharray first-value === 0

Related pages