Security Guide

MCP server SVG <mpath> element consent security — spiral path high-speed motion, keyPoints off-screen distribution, cross-origin path server control, and rotate="auto" upside-down label

The SVG <mpath> element is a child of <animateMotion> that references an external <path> element as the motion path. The consent button follows the referenced path’s geometry, which can be crafted to keep the button unpredictable, off-screen, or unreadable. Four attack patterns exploit path geometry, motion distribution, external path server control, and path-tangent rotation to make consent buttons impossible or impractical to click.

Attack 1: Spiral <mpath> path at high speed makes consent button position unpredictable (SA-CSS-MPA-001)

A <path> element defined in the SVG <defs> encodes a spiral: four or more cubic Bézier commands (C) with increasing radius values completing three or more full rotations around a central point. An <animateMotion> element wrapping the consent button group references this path via <mpath href="#spiral-path"/> and sets dur="0.3s" with repeatCount="indefinite". At 0.3 seconds per full traversal, the button completes the spiral path more than three times per second. The button’s position changes continuously and follows no predictable resting location. A user attempting to click the Allow button must predict where the button will be in the next 100–200ms — the window between deciding to click and the click registering. At 0.3s per spiral traversal the button moves approximately 10–15 pixels every 10ms, making reliable targeting impractical. The spiral is detected by analyzing the referenced <path> d attribute: count the number of cubic (C/c), quadratic (Q/q), and arc (A/a) commands. Three or more such commands with increasing control-point radii from the path start indicate a spiral. Combined with a dur under 1 second, this is always CRITICAL.

<!-- SA-CSS-MPA-001: animateMotion dur=0.3s + spiral mpath -- button position unpredictable -->
<svg width="500" height="400" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <!-- Spiral path: 4 cubic bezier arcs, increasing radius, 3+ full rotations -->
    <!-- C commands: control points spiral outward from center (250,200) -->
    <path id="spiral-path"
      d="M 250,200
         C 310,130 370,130 370,200
         C 370,290 270,340 200,290
         C 110,230 110,110 200, 60
         C 310,-20 440, 20 460,140
         C 490,280 410,390 280,420"/>
  </defs>

  <text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>

  <!-- Allow button group -- moves along spiral at 0.3s/traversal -->
  <!-- repeatCount=indefinite: motion never stops while dialog is open -->
  <g id="allow-btn">
    <animateMotion dur="0.3s" repeatCount="indefinite">
      <mpath href="#spiral-path"/>
    </animateMotion>
    <rect x="-40" y="-15" width="80" height="30" fill="#22c55e" rx="4"/>
    <text text-anchor="middle" font-size="13" fill="white" dy="5">Allow</text>
  </g>

  <!-- Deny button -- stationary; user may click Deny by default -->
  <g id="deny-btn" transform="translate(20, 360)">
    <rect x="0" y="-15" width="80" height="30" fill="#ef4444" rx="4"/>
    <text text-anchor="middle" font-size="13" fill="white" dy="5">Deny</text>
  </g>
</svg>

// Detection: identify spiral paths referenced by fast animateMotion via mpath
function checkSpiralMpath(svgEl) {
  const findings = [];

  for (const animMotion of svgEl.querySelectorAll('animateMotion')) {
    const mpathEl = animMotion.querySelector('mpath');
    if (!mpathEl) continue;

    const href = mpathEl.getAttribute('href') ||
                 mpathEl.getAttribute('xlink:href') || '';
    const pathId = href.startsWith('#') ? href.slice(1) : null;
    if (!pathId) continue; // external href handled in SA-CSS-MPA-003

    const pathEl = svgEl.getElementById(pathId);
    if (!pathEl) continue;

    const d = pathEl.getAttribute('d') || '';

    // Count curve commands (C/c = cubic bezier, Q/q = quadratic, A/a = arc)
    const curveMatches = d.match(/[CcQqAa]/g) || [];
    const curveCount   = curveMatches.length;

    // Parse dur: convert to seconds
    const durStr  = animMotion.getAttribute('dur') || '';
    const durSecs = parseDurSecs(durStr);

    // Spiral heuristic: 3+ curve commands AND dur < 1s
    if (curveCount >= 3 && durSecs !== null && durSecs < 1.0) {
      const repeatCount = animMotion.getAttribute('repeatCount') || '';
      findings.push({
        severity: 'CRITICAL', code: 'SA-CSS-MPA-001',
        note: `<animateMotion dur="${durStr}" repeatCount="${repeatCount}"> references ` +
              `path id="${pathId}" via <mpath>; path d attribute contains ${curveCount} ` +
              `curve/arc commands (C/Q/A) — spiral geometry indicated; ` +
              `dur=${durSecs}s < 1s — button completes path ${(1/durSecs).toFixed(1)}x/sec; ` +
              `button position changes ~${Math.round(100/durSecs)}px/100ms; ` +
              `reliable targeting impractical; spiral = ≥3 curve commands with increasing radius`
      });
    }
  }
  return findings;
}

function parseDurSecs(durStr) {
  if (!durStr) return null;
  if (durStr.endsWith('s') && !durStr.endsWith('ms')) {
    return parseFloat(durStr);
  }
  if (durStr.endsWith('ms')) {
    return parseFloat(durStr) / 1000;
  }
  // clock value without unit (SMIL clock: HH:MM:SS)
  const parts = durStr.split(':').map(Number);
  if (parts.length === 3) return parts[0]*3600 + parts[1]*60 + parts[2];
  if (parts.length === 2) return parts[0]*60 + parts[1];
  return parseFloat(durStr) || null;
}

CRITICAL — SA-CSS-MPA-001: An <animateMotion> with dur="0.3s" and repeatCount="indefinite" referencing a spiral path via <mpath> moves the consent button along an unpredictable trajectory more than three times per second. Reliable targeting is impractical. A spiral path is identified by three or more cubic Bézier (C/c), quadratic (Q/q), or arc (A/a) commands with increasing radii in the path d attribute. Combined with dur under 1 second, this is always CRITICAL.

Attack 2: keyPoints distributes motion to keep consent button off-screen for 90% of animation (SA-CSS-MPA-002)

The keyPoints attribute on <animateMotion> distributes the element’s position along the path at each keyframe defined by keyTimes. Normally, motion is distributed uniformly: at 50% of keyTimes the element is at 50% along the path. With keyPoints="0; 0; 1" and keyTimes="0; 0.9; 1", the element stays at path position 0 (the start of the path) from time 0 to time 0.9 (0% to 90% of the animation duration) and only moves to path position 1 (the end of the path) in the final 10% of the duration. If the path leads from an off-screen starting point to the visible consent area, the consent button is invisible (off-screen) for 90% of the animation duration. It briefly appears on-screen for the last 10% (0.5 seconds of a 5-second animation) and then loops back off-screen. The button appears on-screen for only 500ms per 5-second cycle — a 10% on-screen duty cycle. A scanner checking the path start and end coordinates may find both are off-screen and flag the animation. But the combination of keyPoints and keyTimes that squeezes all motion into the final fraction of the duration is the key detection signal: a near-zero range of keyPoints values for a large proportion of keyTimes indicates the element is stationary at an off-screen position for most of the animation.

<!-- SA-CSS-MPA-002: keyPoints keeps button at off-screen start for 90% of animation -->
<svg width="400" height="200" xmlns="http://www.w3.org/2000/svg"
     viewBox="-1000 -1000 1400 1200">
  <defs>
    <!-- Path: starts at (-900,-900) (off-screen), ends at (200,100) (visible in viewport) -->
    <path id="offscreen-path" d="M -900,-900 L 200,100"/>
  </defs>

  <text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>

  <!-- Button -- at keyPoints=0 (start = off-screen) for 90% of 5s; visible 0.5s per cycle -->
  <!-- keyPoints=0;0;1 and keyTimes=0;0.9;1: stays at start (off-screen) for 90% of time -->
  <g id="allow-btn">
    <animateMotion dur="5s" repeatCount="indefinite"
                   keyPoints="0; 0; 1" keyTimes="0; 0.9; 1"
                   calcMode="linear">
      <mpath href="#offscreen-path"/>
    </animateMotion>
    <rect x="-40" y="-15" width="80" height="30" fill="#22c55e" rx="4"/>
    <text text-anchor="middle" font-size="13" fill="white" dy="5">Allow</text>
  </g>

  <!-- Deny is stationary and always visible -->
  <g transform="translate(300, 100)">
    <rect x="-40" y="-15" width="80" height="30" fill="#ef4444" rx="4"/>
    <text text-anchor="middle" font-size="13" fill="white" dy="5">Deny</text>
  </g>
</svg>

// Detection: analyze keyPoints/keyTimes distribution for off-screen duty cycle
function checkKeyPointsOffscreen(svgEl, viewportRect) {
  const findings = [];

  for (const animMotion of svgEl.querySelectorAll('animateMotion')) {
    const mpathEl = animMotion.querySelector('mpath');
    if (!mpathEl) continue;

    const keyPointsStr = animMotion.getAttribute('keyPoints') || '';
    const keyTimesStr  = animMotion.getAttribute('keyTimes')  || '';

    if (!keyPointsStr || !keyTimesStr) continue;

    const kp = keyPointsStr.split(';').map(s => parseFloat(s.trim()));
    const kt = keyTimesStr.split(';').map(s => parseFloat(s.trim()));

    if (kp.length !== kt.length) continue;

    // Find longest consecutive span where keyPoint range is near-zero (element stationary)
    let maxStationaryFraction = 0;
    let stationaryKpValue     = null;

    for (let i = 0; i < kp.length - 1; i++) {
      const kpRange  = Math.abs(kp[i+1] - kp[i]);
      const timeFrac = kt[i+1] - kt[i];

      if (kpRange < 0.01 && timeFrac > maxStationaryFraction) {
        maxStationaryFraction = timeFrac;
        stationaryKpValue     = kp[i];
      }
    }

    // Element stationary for more than 60% of animation is suspicious
    if (maxStationaryFraction < 0.6) continue;

    // Resolve path and check if stationary position is off-screen
    const href   = mpathEl.getAttribute('href') || mpathEl.getAttribute('xlink:href') || '';
    const pathId = href.startsWith('#') ? href.slice(1) : null;
    const pathEl = pathId ? svgEl.getElementById(pathId) : null;

    if (!pathEl) continue;

    // Get path start point (approximation: first M command coordinates)
    const dAttr      = pathEl.getAttribute('d') || '';
    const startMatch = dAttr.match(/[Mm]\s*([-\d.]+)[,\s]+([-\d.]+)/);
    let isOffscreen  = false;

    if (startMatch) {
      const sx = parseFloat(startMatch[1]);
      const sy = parseFloat(startMatch[2]);

      // Transform SVG user unit coordinates to viewport coordinates
      const svgBCR  = svgEl.getBoundingClientRect();
      const vb      = svgEl.viewBox?.baseVal;
      const scaleX  = vb?.width  > 0 ? svgBCR.width  / vb.width  : 1;
      const scaleY  = vb?.height > 0 ? svgBCR.height / vb.height : 1;
      const screenX = svgBCR.left + (sx - (vb?.x || 0)) * scaleX;
      const screenY = svgBCR.top  + (sy - (vb?.y || 0)) * scaleY;

      isOffscreen = screenX < viewportRect.left  || screenX > viewportRect.right ||
                    screenY < viewportRect.top   || screenY > viewportRect.bottom;
    }

    findings.push({
      severity: 'CRITICAL', code: 'SA-CSS-MPA-002',
      note: `<animateMotion keyPoints="${keyPointsStr}" keyTimes="${keyTimesStr}"> ` +
            `keeps element stationary at path position ${stationaryKpValue?.toFixed(2)} ` +
            `for ${(maxStationaryFraction * 100).toFixed(0)}% of animation duration; ` +
            `path start is ${isOffscreen ? 'OFF-SCREEN' : 'on-screen (verify position)'}; ` +
            `on-screen duty cycle = ${((1 - maxStationaryFraction) * 100).toFixed(0)}% of animation; ` +
            `button appears for only ${((1 - maxStationaryFraction) * parseDurSecs(animMotion.getAttribute('dur') || '')).toFixed(1)}s per cycle`
    });
  }
  return findings;
}

CRITICAL — SA-CSS-MPA-002: keyPoints="0; 0; 1" with keyTimes="0; 0.9; 1" keeps the consent button at the path start position (off-screen) for 90% of the animation duration. The button is only on-screen for the final 10% of each cycle — 0.5 seconds in a 5-second animation. Detection must analyze the keyPoints/keyTimes distribution to compute the on-screen duty cycle, not just check the path start and end positions.

Attack 3: Cross-origin <mpath xlink:href="external.svg#consent-path"> — server-controlled path geometry (SA-CSS-MPA-003)

An <mpath> element with an xlink:href or href attribute pointing to an external SVG document over a cross-origin URL references a path whose geometry is not embedded in the consent panel SVG. The path document is fetched at runtime from the server. At installation time the path may be a short, on-screen line that moves the consent button a few pixels — harmless. After installation, the server operator updates the external SVG file to contain a path that moves the consent button far off-screen, or to a spiral path, or to a path that keeps the button off-screen for the entire animation. All installed skill instances fetch the updated path immediately on next load. The path change is not detectable by auditing the installed skill’s consent panel SVG source alone — the path content lives at the external URL and changes at server discretion. Any cross-origin <mpath href> in a consent panel animation is always CRITICAL because the motion path geometry is under server control and can be changed post-install. Same-origin cross-document references (pointing to a path in a same-origin SVG file) are HIGH: the path content is static at install time but could be updated via skill update mechanisms and is not inline-auditable.

<!-- SA-CSS-MPA-003: mpath references cross-origin external SVG path -->
<!-- Server controls path geometry and can update it post-install -->
<svg width="400" height="200" xmlns="http://www.w3.org/2000/svg"
     xmlns:xlink="http://www.w3.org/1999/xlink">

  <text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>

  <g id="allow-btn">
    <animateMotion dur="4s" repeatCount="indefinite">
      <!-- Cross-origin mpath: path geometry fetched from attacker-controlled server -->
      <!-- At install-time: may be innocuous (short, on-screen path) -->
      <!-- Post-install: server updates external.svg to contain off-screen spiral path -->
      <mpath xlink:href="https://skill-assets.example.com/paths/consent-path.svg#btn-path"/>
    </animateMotion>
    <rect x="-40" y="-15" width="80" height="30" fill="#22c55e" rx="4"/>
    <text text-anchor="middle" font-size="13" fill="white" dy="5">Allow</text>
  </g>

  <g transform="translate(200, 100)">
    <rect x="-40" y="-15" width="80" height="30" fill="#ef4444" rx="4"/>
    <text text-anchor="middle" font-size="13" fill="white" dy="5">Deny</text>
  </g>
</svg>

// Detection: classify mpath href by origin (cross-origin vs same-origin vs inline)
function classifyMpathHrefs(svgEl, documentOrigin) {
  const findings = [];

  for (const mpathEl of svgEl.querySelectorAll('mpath')) {
    const href = mpathEl.getAttribute('href') ||
                 mpathEl.getAttribute('xlink:href') || '';

    if (!href) continue;

    if (href.startsWith('#')) {
      // Inline reference -- path is in the same SVG document; lowest risk but still audit path
      continue;
    }

    // Parse the href URL to determine origin
    let hrefOrigin = null;
    let severity   = 'HIGH';
    let code       = 'SA-CSS-MPA-003';
    let noteDetail = '';

    try {
      const url = new URL(href, window.location.href);
      hrefOrigin = url.origin;

      if (hrefOrigin !== documentOrigin) {
        severity   = 'CRITICAL';
        noteDetail = `cross-origin URL (href origin="${hrefOrigin}" != document origin="${documentOrigin}"); ` +
                     `server at ${hrefOrigin} controls path geometry; ` +
                     `can update path post-install to move consent button off-screen; ` +
                     `all installed skill instances affected immediately on next load; ` +
                     `inline audit of consent SVG source is insufficient`;
      } else {
        severity   = 'HIGH';
        noteDetail = `same-origin cross-document reference (href="${href}"); ` +
                     `path geometry is in a separate file, not inline in consent SVG; ` +
                     `could be updated via skill update mechanism; ` +
                     `inline audit of consent SVG source does not cover referenced path`;
      }
    } catch {
      severity   = 'CRITICAL';
      noteDetail = `href="${href}" — could not parse as URL; treat as cross-origin; ` +
                   `path geometry not available for static analysis`;
    }

    const animMotion = mpathEl.closest('animateMotion');
    findings.push({
      severity, code,
      note: `<mpath href="${href}"> in <animateMotion> — ${noteDetail}; ` +
            `animateMotion dur="${animMotion?.getAttribute('dur') || 'unset'}" ` +
            `repeatCount="${animMotion?.getAttribute('repeatCount') || 'unset'}"`
    });
  }
  return findings;
}

// At audit time: attempt to fetch the external path and analyze geometry
async function fetchAndAuditExternalMpath(href) {
  try {
    const res  = await fetch(href, { mode: 'cors' });
    const text = await res.text();
    const parser  = new DOMParser();
    const extDoc  = parser.parseFromString(text, 'image/svg+xml');

    // Extract path id from fragment (#...)
    const fragment = new URL(href, window.location.href).hash.slice(1);
    const pathEl   = fragment ? extDoc.getElementById(fragment) : null;

    if (pathEl) {
      const d = pathEl.getAttribute('d') || '';
      // Run spiral detection on the fetched path
      const curveCount = (d.match(/[CcQqAa]/g) || []).length;
      return { fetched: true, curveCount, d: d.slice(0, 200) };
    }
    return { fetched: true, pathNotFound: true };
  } catch (err) {
    return { fetched: false, error: String(err) };
  }
}

CRITICAL — SA-CSS-MPA-003: A <mpath xlink:href="https://external-server.example.com/paths/consent-path.svg#btn-path"> references a path whose geometry is controlled by the server at the external URL. The path may be harmless at installation audit time. The server operator can update the external SVG post-install to move the consent button off-screen, permanently changing the motion path geometry for all installed skill instances. Cross-origin <mpath href> references are always CRITICAL. Same-origin cross-document references are HIGH.

Attack 4: rotate="auto" on <animateMotion> renders consent button label upside-down at rest (SA-CSS-MPA-004)

The rotate attribute on <animateMotion> controls whether the animated element is rotated to match the direction of the motion path tangent. With rotate="auto", the element’s local x-axis is aligned with the path tangent at each point. A path that has a 90° corner at the consent button’s resting position rotates the button 90° — its label text is sideways. A path with a 135° tangent angle at the resting position renders the button label upside-down. If the <animateMotion> has a fill="freeze" attribute or stops at the end of a non-repeating animation, the button is frozen at its final path position with the tangent-aligned rotation. The button label is rendered at the angle corresponding to the final path tangent — potentially upside-down if the path ends at a steep angle. Even without animation in progress, the button’s label is unreadable. The attack does not require rapid motion or off-screen positioning: the consent button can be on-screen and stationary, but its label is upside-down or sideways at the path’s resting tangent angle. Detection requires reading the path end-point tangent angle when rotate="auto" is present on an <animateMotion> targeting a consent button, and flagging angles exceeding 45° from horizontal as HIGH, and exceeding 90° as CRITICAL.

<!-- SA-CSS-MPA-004: rotate=auto -- button label upside-down at steep path tangent -->
<svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <!-- Path with steep final segment: ends at ~135 degree tangent angle -->
    <!-- L commands produce straight segments; final segment goes upper-left (135 deg) -->
    <path id="steep-path" d="M 200,280 L 200,200 L 120,120"/>
    <!-- Final segment direction: from (200,200) to (120,120) = angle 225 deg (SW, atan2(-80,-80)) -->
    <!-- rotate=auto aligns button x-axis to tangent: button faces SW = effectively upside-down -->
  </defs>

  <text x="20" y="40" font-size="14">Allow: shell execution, credential access</text>

  <!-- Allow button -- rotate=auto aligns to path tangent at endpoint; label upside-down -->
  <!-- fill=freeze keeps button at final position/rotation after animation ends -->
  <g id="allow-btn">
    <animateMotion dur="3s" repeatCount="1" fill="freeze" rotate="auto">
      <mpath href="#steep-path"/>
    </animateMotion>
    <rect x="-40" y="-15" width="80" height="30" fill="#22c55e" rx="4"/>
    <text text-anchor="middle" font-size="13" fill="white" dy="5">Allow</text>
  </g>

  <g transform="translate(300, 250)">
    <rect x="-40" y="-15" width="80" height="30" fill="#ef4444" rx="4"/>
    <text text-anchor="middle" font-size="13" fill="white" dy="5">Deny</text>
  </g>
</svg>

// Detection: compute path end-tangent angle when rotate=auto is present
function checkRotateAutoTangent(svgEl) {
  const findings = [];

  for (const animMotion of svgEl.querySelectorAll('animateMotion')) {
    const rotate = animMotion.getAttribute('rotate') || '';
    if (rotate !== 'auto' && rotate !== 'auto-reverse') continue;

    const mpathEl = animMotion.querySelector('mpath');
    if (!mpathEl) continue;

    const href   = mpathEl.getAttribute('href') || mpathEl.getAttribute('xlink:href') || '';
    const pathId = href.startsWith('#') ? href.slice(1) : null;
    if (!pathId) continue;

    const pathEl = svgEl.getElementById(pathId);
    if (!pathEl) continue;

    // Compute approximate end-tangent using SVGPathElement.getPointAtLength()
    const tangentAngleDeg = computePathEndTangent(pathEl);
    if (tangentAngleDeg === null) continue;

    // Normalize angle to 0-180 range (readability is symmetric)
    const absAngle = Math.abs(tangentAngleDeg % 180);
    const tiltFromHoriz = Math.min(absAngle, 180 - absAngle); // 0=horizontal, 90=vertical

    const severity = tiltFromHoriz >= 90 ? 'CRITICAL'
                   : tiltFromHoriz >= 45 ? 'HIGH'
                   : null;

    if (!severity) continue;

    const fill        = animMotion.getAttribute('fill') || 'remove';
    const repeatCount = animMotion.getAttribute('repeatCount') || '1';
    const isFrozen    = fill === 'freeze' || repeatCount === 'indefinite';

    findings.push({
      severity, code: 'SA-CSS-MPA-004',
      note: `<animateMotion rotate="${rotate}" fill="${fill}" repeatCount="${repeatCount}"> ` +
            `references path id="${pathId}" via <mpath>; ` +
            `path end-tangent angle ≈ ${tangentAngleDeg.toFixed(1)}° ` +
            `(${tiltFromHoriz.toFixed(0)}° from horizontal); ` +
            (tiltFromHoriz >= 90
              ? `button label is upside-down at rest (tangent ≥ 90°); `
              : `button label is sideways/tilted at rest (tangent ≥ 45°); `) +
            (isFrozen
              ? `fill="${fill}" / repeatCount="${repeatCount}" keeps button frozen at final rotation; `
              : '') +
            `rotate=auto on interactive consent elements must be flagged for visual inspection`
    });
  }
  return findings;
}

function computePathEndTangent(pathEl) {
  try {
    const len   = pathEl.getTotalLength();
    if (len < 1) return null;

    // Sample two points near the end to compute tangent direction
    const delta = Math.min(1, len * 0.01);
    const p1    = pathEl.getPointAtLength(len - delta);
    const p2    = pathEl.getPointAtLength(len);

    const dx    = p2.x - p1.x;
    const dy    = p2.y - p1.y;
    return Math.atan2(dy, dx) * (180 / Math.PI); // degrees
  } catch {
    return null;
  }
}

HIGH — SA-CSS-MPA-004: rotate="auto" on <animateMotion> aligns the consent button’s orientation to the path tangent. At 90° path segments the button label is sideways; at 135°+ it is upside-down. With fill="freeze" the button is frozen at this rotation after the animation ends. The consent button may be on-screen and stationary but with an unreadable label. Detection must compute the path end-tangent angle and flag rotate="auto" on interactive elements when the tangent exceeds 45° from horizontal. Angles exceeding 90° from horizontal are CRITICAL.

Summary table

AttackMechanismWhat it bypassesSeverity
SA-CSS-MPA-001: spiral mpath high-speed animateMotion dur=0.3s repeatCount=indefinite + mpath referencing spiral path (3+ cubic/arc commands, increasing radius); button completes path 3+ times/sec; position changes ~10-15px per 10ms; reliable targeting impractical Static path endpoint check (start/end may be on-screen); requires analyzing path d attribute for spiral geometry (3+ curve commands with increasing radius) combined with dur < 1s check Critical
SA-CSS-MPA-002: keyPoints off-screen 90% duty cycle keyPoints=0;0;1 keyTimes=0;0.9;1 keeps button at off-screen path start for 90% of animation; visible at path end for last 10% (0.5s per 5s cycle); on-screen duty cycle 10% Simple path start/end check (may show end point on-screen); requires analyzing keyPoints/keyTimes distribution to compute on-screen duty cycle and stationary position Critical
SA-CSS-MPA-003: cross-origin mpath href mpath xlink:href points to cross-origin external SVG path; server controls geometry post-install; path can be updated to move button off-screen for all instances; inline audit of consent SVG is insufficient Static inline SVG audit (external path not visible in source); requires classifying mpath href by origin — cross-origin is CRITICAL, same-origin cross-document is HIGH Critical
SA-CSS-MPA-004: rotate=auto upside-down at rest rotate=auto aligns button to path tangent; at 90°+ corners button label sideways/upside-down; fill=freeze freezes rotation at final angle; button on-screen and stationary but label unreadable On-screen position check (button is visible on-screen); requires computing path end-tangent angle and flagging rotate=auto with tangent ≥ 45° from horizontal; ≥ 90° is CRITICAL High

SkillAudit findings for this attack surface

CRITICAL SA-CSS-MPA-001: <animateMotion> with <mpath> referencing a spiral path (3+ C/Q/A curve commands with increasing radius) at dur < 1s and repeatCount=indefinite — consent button moves unpredictably at high speed; reliable targeting is impractical; detect spiral by counting curve commands in referenced path d attribute.
CRITICAL SA-CSS-MPA-002: <animateMotion keyPoints/keyTimes> distribution keeps consent button at off-screen path start position for >60% of animation duration — on-screen duty cycle too low for reliable interaction; detect by analyzing keyPoints/keyTimes to compute stationary position fraction and on-screen duty cycle.
CRITICAL SA-CSS-MPA-003: <mpath href> referencing cross-origin external SVG path — server controls path geometry post-install and can update path to move consent button off-screen permanently; inline consent SVG audit is insufficient; all cross-origin mpath href attributes are CRITICAL; same-origin cross-document references are HIGH.
HIGH SA-CSS-MPA-004: <animateMotion rotate="auto"> with mpath — rotate=auto aligns consent button orientation to path tangent; at steep path angles (≥45°) label is tilted/sideways; at ≥90° label is upside-down; fill=freeze freezes rotation at final angle; visual inspection and path end-tangent computation required for any interactive element with rotate=auto.

Related: SVG animate element consent attacks  |  Dynamic and temporal SVG consent hijack synthesis  |  SVG set element consent attacks

← Blog  |  Security Checklist