Security Guide

MCP server CSS stroke-miterlimit consent security — miter spike overlay, sharp angle extension, auto-bevel evasion, and nested path join attack

The SVG stroke-miterlimit property controls the maximum distance a miter join extends at a sharp-angle path corner before the browser falls back to a bevel join. At very sharp angles, a miter join can extend many multiples of the stroke-width beyond the path corner — producing a long “spike” in the bisector direction. With stroke-miterlimit: 200 and a 2° corner angle, the spike extends 228px for a stroke-width of just 8px. This spike is a solid white filled triangle pointing across the consent text area. Auditors checking only stroke-width for coverage analysis see a thin 8px stroke and consider it harmless, missing the 228px spike geometry entirely. MCP servers can escalate miterlimit dynamically at interaction time, flip the join type at click, or hide the large miterlimit inside nested SVG containers to evade static detection.

Attack 1: stroke-miterlimit: 200 on a 2° V-path creates 228px miter spike across consent text (SA-CSS-SML-001)

The SVG miter join extends at path corners in the direction of the angle bisector between the two joining path segments. The miter length (distance from the inner corner to the spike tip) equals stroke-width / (2 × sin(θ/2)), where θ is the angle between the two incoming segments. At θ = 2°, sin(1°) ≈ 0.01745, giving a miter ratio of 1 / sin(1°) ≈ 57. With stroke-width: 8, the full miter spike extends 8/2 × 57 ≈ 228px beyond the corner tip. The spike is a solid white triangle — the same color as the stroke — that “shoots” across the consent panel in the bisector direction. An MCP server using a V-shape path whose apex points at the consent text area, with stroke-miterlimit: 200 keeping the full miter rendered at the 2° angle, produces a 228px white triangle covering the consent text. Auditors that check strokeWidth: 8 and conclude “thin stroke, no coverage risk” miss the miter geometry entirely.

<!-- SA-CSS-SML-001: stroke-miterlimit:200, stroke-width:8, sharp V-path at 2°
     miter spike at apex: length = 8/2 × 1/sin(1°) ≈ 228px pointing right
     228px white triangle covers the consent text block to the right of the V apex -->

<svg width="500" height="300" style="position:fixed; top:60px; left:20px; overflow:visible">
  <!-- V-path: two arms meeting at (100, 150) at a 2° included angle
       Left arm runs from (0, 148) to (100, 150)
       Right arm runs from (100, 150) to (200, 152)
       Included angle between arms ≈ 2° —>
  <path
    d="M 0,148 L 100,150 L 200,152"
    stroke="white"
    stroke-width="8"
    stroke-linejoin="miter"
    stroke-miterlimit="200"
    fill="none"
  />
  <!--
    At the corner (100,150):
      θ = angle between the two segments ≈ 2°
      miter ratio = 1/sin(1°) ≈ 57.3
      miter spike length = 8/2 × 57.3 = 228.9 px
      spike direction: bisector of the two arms (pointing right toward consent text)
      spike tip reaches approximately x = 100 + 228 = 328, y = 150

    Consent text block is positioned at x=110, y=140 to x=450, y=200
    The 228px white miter triangle covers this area completely.

    getComputedStyle signals:
      strokeWidth      → "8"         ← looks thin and harmless
      strokeMiterlimit → "200"       ← key attack signal
      strokeLinejoin   → "miter"     ← required for miter join to be active

    Auditor naive check: strokeWidth(8) < threshold(10) → skip coverage check
    Correct check: compute miter spike length and direction for each corner
  -->
</svg>

// Detection: compute miter spike length at each path corner
function checkMiterSpike(pathEl, consentTextEl) {
  const cs = getComputedStyle(pathEl);
  const sw = parseFloat(cs.strokeWidth);
  const ml = parseFloat(cs.strokeMiterlimit);
  if (cs.strokeLinejoin !== 'miter') return false;

  // For each corner angle θ in the path:
  //   miter ratio = 1 / sin(θ/2)
  //   if miter ratio ≤ ml: full miter renders, spike length = sw/2 × miter_ratio
  //   if miter ratio > ml: bevel fallback, no spike

  // Example: corner at θ = 2° (0.0349 radians)
  const thetaRad = 2 * Math.PI / 180;
  const miterRatio = 1 / Math.sin(thetaRad / 2);  // ≈ 57.3
  if (miterRatio > ml) return false;  // bevelled — no spike

  const spikeLength = (sw / 2) * miterRatio;  // ≈ 228px at sw=8

  // Compute spike tip position (bisector direction from corner point)
  // ... (requires path corner coordinate extraction)
  console.warn('Miter spike length:', spikeLength.toFixed(1), 'px — check coverage against consent text');

  // Flag: spikeLength > threshold or spike tip rect overlaps consent text bounding box
  const textRect = consentTextEl.getBoundingClientRect();
  // (intersection test against spike triangle bounding box)
  return spikeLength > 50; // simplified threshold flag
}

CRITICAL — SA-CSS-SML-001: A stroke of width 8px appears thin in any audit that checks strokeWidth against a fixed threshold. The 228px miter spike is invisible to width-based checks. Detection requires checking strokeMiterlimit value, identifying sharp-angle corners in the path, computing spikeLength = strokeWidth/2 × 1/sin(θ/2) for each corner, and flagging spikes that point toward the consent text area. SkillAudit checks all stroke-linejoin: miter paths in consent-adjacent SVG areas for large-miterlimit sharp-angle spike geometry.

Attack 2: Dynamic miterlimit escalation via CSS custom property at mousedown (SA-CSS-SML-002)

The consent panel border path uses stroke-miterlimit: var(--ml, 4). At page load, --ml is unset, so the fallback value of 4 applies. The SVG specification defines that when the miter ratio at a corner exceeds the miterlimit, the join falls back to a bevel — no spike is produced. At --ml: 4, even moderately sharp angles (below approximately 29°) fall back to bevel joins. The path corners and consent text are both legible at this static value. A mousedown event listener attached to the consent panel sets document.documentElement.style.setProperty('--ml', '1000') before the click event fires. At --ml: 1000, even very shallow path angles produce full miter joins with long spikes. The consent text, which was visible during static rendering, is covered by spikes at the exact moment the user clicks the confirm button. The static audit — which reads computed styles before user interaction — sees stroke-miterlimit: 4 and reports the panel as safe.

/* SA-CSS-SML-002: CSS custom property escalation — static audit sees --ml:4 (safe),
   mousedown sets --ml:1000 just before the click event fires */

<style>
  :root { --ml: 4; }
  .consent-border {
    stroke: white;
    stroke-width: 12;
    stroke-linejoin: miter;
    stroke-miterlimit: var(--ml);  /* ← escalated at mousedown */
  }
</style>

<svg width="460" height="260">
  <!-- Consent border path with several moderately sharp-angle corners (15°–25°) -->
  <path class="consent-border"
    d="M 10,10 L 450,10 L 455,50 L 450,250 L 10,250 Z"
    fill="rgba(255,255,255,0.95)"
  />
</svg>

<!-- Confirm button -->
<button id="confirm-btn">I agree and install</button>

<script>
document.getElementById('confirm-btn').addEventListener('mousedown', () => {
  // Escalate before click fires — consent text now covered by miter spikes
  document.documentElement.style.setProperty('--ml', '1000');
});
</script>

/* Static audit signals (INSUFFICIENT):
 *   getComputedStyle(pathEl).strokeMiterlimit  → "4"  ← safe, no long spikes
 *
 * Detection:
 *   1. Check for CSS custom properties (--*) used in stroke-miterlimit declarations
 *      via CSSStyleDeclaration and CSSStyleSheet inspection
 *   2. Search for event listeners that write to CSS custom properties
 *      used in stroke-miterlimit — flag mousedown/click/touchstart listeners
 *      that call style.setProperty('--ml', ...) or similar patterns
 *   3. Simulate a synthetic mousedown event before auditing computed styles:
 */
function auditWithSyntheticInteraction(consentEl, pathEl) {
  // Fire synthetic mousedown to trigger any pre-click escalation
  consentEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
  // Re-read computed styles after the event fires
  const ml = parseFloat(getComputedStyle(pathEl).strokeMiterlimit);
  if (ml > 10) {
    console.warn('strokeMiterlimit escalated after mousedown:', ml);
    // proceed with miter spike geometry analysis
  }
}

HIGH — SA-CSS-SML-002: Dynamic strokeMiterlimit escalation via CSS custom property writes at mousedown is invisible to any static computed-style audit. The attack surface exists whenever a CSS custom property is used in a stroke-miterlimit value. SkillAudit fires synthetic interaction events before auditing SVG stroke properties to catch pre-click property escalation patterns, and inspects event listener sources for custom property writes on variables used in miter-related stroke declarations.

Attack 3: stroke-linejoin: bevel evasion — join type flipped to miter at interaction (SA-CSS-SML-003)

An MCP server aware that auditors check stroke-linejoin !== 'bevel' before considering stroke-miterlimit relevant can render the consent border path with stroke-linejoin: bevel at static inspection time. Bevel joins produce flat cut corners regardless of the miterlimit value — stroke-miterlimit is defined to have no effect when the join type is bevel or round. An auditor that checks strokeLinejoin and sees "bevel" correctly concludes that miterlimit is irrelevant and skips miter spike analysis. A mousedown event listener then sets el.style.strokeLinejoin = 'miter', re-enabling miter joins with the existing large stroke-miterlimit: 500 value. The combination of join type and miterlimit is a two-property dynamic attack: neither property alone triggers the attack at static audit time. At the moment of user interaction, both properties combine to produce long miter spikes covering the consent text.

/* SA-CSS-SML-003: stroke-linejoin flipped from bevel to miter at mousedown —
   two-property dynamic attack: linejoin type + miterlimit value */

<style>
  .consent-border-path {
    stroke: white;
    stroke-width: 10;
    stroke-linejoin: bevel;       /* ← static audit sees "bevel" → skip miter check */
    stroke-miterlimit: 500;       /* ← has no effect with bevel, but ready to activate */
  }
</style>

<svg width="460" height="260">
  <path class="consent-border-path"
    d="M 50,10 L 410,10 L 450,30 L 450,250 L 10,250 L 10,30 Z"
    fill="white"
  />
</svg>

<button id="agree-btn">I Agree</button>
<script>
document.getElementById('agree-btn').addEventListener('mousedown', (e) => {
  // Flip join type just before click fires
  document.querySelector('.consent-border-path').style.strokeLinejoin = 'miter';
  // Now stroke-miterlimit:500 is active; sharp corners produce 500× strokeWidth spikes
});
</script>

/* Static audit (VULNERABLE to this evasion):
 *   getComputedStyle(pathEl).strokeLinejoin   → "bevel"  ← skip miter analysis
 *   getComputedStyle(pathEl).strokeMiterlimit → "500"    ← not checked (bevel = irrelevant)
 *
 * Correct detection strategy:
 *   Flag strokeMiterlimit > 10 as a risk signal REGARDLESS of strokeLinejoin value.
 *   Reason: strokeLinejoin can be dynamically changed to 'miter' at any time,
 *   re-activating the large miterlimit without any further property changes.
 *
 *   Additionally: fire synthetic mousedown before auditing, as in SA-CSS-SML-002.
 */

function auditMiterlimitRegardlessOfJoin(pathEl) {
  const cs = getComputedStyle(pathEl);
  const ml = parseFloat(cs.strokeMiterlimit);
  // Do NOT gate this check on strokeLinejoin === 'miter':
  if (ml > 10) {
    console.warn(
      'Large strokeMiterlimit (' + ml + ') present on path — ' +
      'risk: strokeLinejoin may be dynamically changed to miter. ' +
      'Current join type: ' + cs.strokeLinejoin
    );
  }
}

HIGH — SA-CSS-SML-003: Auditors that check strokeLinejoin first and skip strokeMiterlimit analysis for non-miter join types create a reliable evasion path. A large stroke-miterlimit value is a risk signal regardless of the current join type, because the join type can be dynamically switched to miter at any point by JavaScript. SkillAudit flags strokeMiterlimit > 10 on any stroked path in consent areas, independent of the current strokeLinejoin value, and tests for dynamic join-type escalation with synthetic interaction events.

Attack 4: nested <svg> inner miterlimit override hidden from outer container audit (SA-CSS-SML-004)

SVG allows nesting: an <svg> element can appear inside another <svg> element. Each <svg> establishes its own presentation attribute context. An outer <svg> consent container can have stroke-miterlimit: 1 set — a safe value that causes bevel fallback at any angle greater than 0° effectively eliminating all miter spikes. Path elements that inherit from the outer SVG will have stroke-miterlimit: 1 computed. An inner <svg> nested inside resets the value via its own CSS rule or presentation attribute: stroke-miterlimit: 100. Path elements inside the inner SVG inherit stroke-miterlimit: 100 from the inner SVG container, not 1 from the outer. An auditor that calls getComputedStyle(outerSvgEl).strokeMiterlimit receives "1" and considers the consent panel safe, missing the inner SVG’s override.

/* SA-CSS-SML-004: nested SVG miterlimit override —
   outer SVG reports strokeMiterlimit:1, inner SVG sets it to 100 */

<!-- Outer SVG consent container — stroke-miterlimit:1 (safe: bevel everything) -->
<svg id="consent-outer" width="460" height="300"
     style="stroke-miterlimit:1; stroke:white; stroke-width:10; stroke-linejoin:miter">

  <!-- Safe outer path: inherits strokeMiterlimit:1 → bevel at all sharp angles -->
  <rect x="5" y="5" width="450" height="290" fill="rgba(255,255,255,0.95)" />

  <!-- Inner SVG resets stroke-miterlimit to 100 -->
  <svg x="10" y="10" width="440" height="200"
       style="stroke-miterlimit:100">
    <!-- Paths inside here inherit strokeMiterlimit:100 from inner SVG -->
    <!-- Sharp-angle corner path covering consent text area: -->
    <path
      d="M 0,50 L 220,52 L 440,50"
      stroke="white"
      stroke-width="10"
      stroke-linejoin="miter"
      fill="none"
    />
    <!-- At 2° angle: miter spike = 10/2 × 1/sin(1°) ≈ 286px
         stroke-miterlimit:100 > miter ratio 57 → full spike renders -->
  </svg>
</svg>

/* Audit signal (INSUFFICIENT):
 *   getComputedStyle(document.getElementById('consent-outer')).strokeMiterlimit
 *     → "1"  ← safe value — bevel at any angle
 *
 * The inner SVG's value is completely missed by auditing the outer container.
 *
 * Detection: walk all descendant SVG elements, not just the consent container
 */
function auditAllDescendantSVGs(consentContainer) {
  // Check the container itself
  const els = [consentContainer, ...consentContainer.querySelectorAll('svg')];
  els.forEach((svgEl, depth) => {
    const ml = parseFloat(getComputedStyle(svgEl).strokeMiterlimit);
    if (ml > 10) {
      console.warn(
        'SVG at depth ' + depth + ' has strokeMiterlimit: ' + ml +
        ' — check all descendant path elements for sharp-angle miter spikes'
      );
      // Also check all path elements inside this specific SVG
      svgEl.querySelectorAll(':scope > path, :scope > polyline, :scope > line')
        .forEach(pathEl => {
          const pml = parseFloat(getComputedStyle(pathEl).strokeMiterlimit);
          const sw  = parseFloat(getComputedStyle(pathEl).strokeWidth);
          if (pml > 10 && sw > 0) {
            console.warn('  Path with miterlimit:', pml, 'strokeWidth:', sw);
          }
        });
    }
  });
}

MEDIUM — SA-CSS-SML-004: Auditing the outermost consent container SVG element’s strokeMiterlimit value is insufficient when inner nested <svg> elements override the value. Each <svg> element forms an independent presentation attribute context. SkillAudit walks all descendant <svg> elements within the consent-flagged area, checking strokeMiterlimit at each level, and identifies any inner SVG container that raises the value above the safe threshold set by the outer container.

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-SML-001: stroke-miterlimit: 200 at 2° sharp angle Miter spike length = strokeWidth/2 × 1/sin(θ/2); at 2° with stroke-width:8, spike extends 228px beyond corner toward consent text Entire consent text block covered by 228px white miter triangle; auditors checking strokeWidth:8 report thin stroke harmless Critical
SA-CSS-SML-002: CSS custom property --ml escalated at mousedown Static --ml:4 (bevel fallback); mousedown sets --ml:1000 before click fires, creating long spikes at interaction time Consent text legible during static audit; covered by spikes at click moment; static audit reports strokeMiterlimit:4 as safe High
SA-CSS-SML-003: stroke-linejoin flipped from bevel to miter at interaction Static bevel join disables miterlimit effect; mousedown sets strokeLinejoin = 'miter', re-enabling stroke-miterlimit:500 Two-property dynamic combination; static audit sees bevel and skips miter analysis; large miterlimit activates at click High
SA-CSS-SML-004: nested <svg> inner stroke-miterlimit:100 override Outer SVG has stroke-miterlimit:1 (safe); inner nested <svg> resets to 100; inner paths inherit 100, not 1 Audit on outer container returns strokeMiterlimit:1; inner SVG paths produce long spikes; only caught by walking all descendant SVG elements Medium

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-SML-001: stroke-miterlimit: 200; stroke-width: 8; stroke: white; stroke-linejoin: miter on V-shape path with 2° corner — miter spike length 228px covers consent text block; strokeWidth:8 audit considers stroke thin and harmless; detection requires computing spikeLength = strokeWidth/2 × 1/sin(θ/2) at each sharp corner and checking spike triangle intersection with consent text bounding box.
HIGH SA-CSS-SML-002: stroke-miterlimit: var(--ml, 4) with --ml escalated from 4 to 1000 via mousedown listener on confirm button — static audit reads strokeMiterlimit:4 (safe); at click time, --ml:1000 produces long miter spikes covering consent text; detection requires monitoring CSS custom property writes and firing synthetic interaction events before auditing computed stroke properties.
HIGH SA-CSS-SML-003: stroke-linejoin: bevel at static audit time with stroke-miterlimit: 500 — mousedown sets strokeLinejoin = 'miter' re-enabling miterlimit effect; static audit sees bevel and skips miter analysis; correct mitigation: flag strokeMiterlimit > 10 as a risk signal regardless of current join type; test join-type escalation with synthetic mousedown.
MEDIUM SA-CSS-SML-004: outer <svg> consent container with stroke-miterlimit:1; inner nested <svg> overrides to stroke-miterlimit:100 — getComputedStyle(outerSvgEl).strokeMiterlimit returns "1"; inner path elements inherit 100 from inner SVG and produce long miter spikes; detection requires walking all descendant <svg> elements and checking strokeMiterlimit at each level independently.

Related: SVG fill-rule consent attacks  |  stroke-dasharray consent attacks  |  shape-rendering consent attacks  |  vector-effect consent attacks

← Blog  |  Security Checklist