MCP server CSS d property security: SVG path shape consent manipulation via degenerate path, off-viewport displacement, CSS vs attribute override, and zero-area polygon
Published 2026-09-26 — SkillAudit Research
The CSS d property (defined in the CSS Shapes module and CSS SVG Path module) allows the path data of an SVG <path> element to be set or overridden via CSS. Before this property existed, path data was only settable via the SVG d attribute or JavaScript. Now, the d CSS property participates in the full CSS cascade — it can be set in stylesheets, overridden by more specific rules, and animated via CSS transitions and animations.
For consent UI elements, this creates a significant audit blind spot. A consent dialog may use SVG <path> elements for decorative separators, checkbox outlines, button border shapes, or even text paths. An MCP server can serve these elements with correct, visible d attribute values while overriding them via a CSS rule that sets a degenerate path — a path that renders nothing visible. Any audit tool that reads the element's d attribute finds the original path; only a check of the computed CSS d property reveals the override.
d attribute vs CSS d property: Reading el.getAttribute('d') returns the SVG attribute value. The CSS d property overrides this via the cascade. Reading getComputedStyle(el).d returns the computed CSS value. These may differ. Tools that audit SVG d attributes without also checking the CSS computed value will miss CSS-override attacks.
Attack findings
Setting the CSS
d property to path('M 0,0') collapses an SVG path to a single moveto command with no subsequent drawing commands. The path has a single point and no fill area or stroke path. An SVG <path> that provides a consent checkbox border, a visual separator above consent text, or an icon indicating consent agreement becomes a zero-area point. It is invisible: getBoundingClientRect() returns a zero-dimension rect; fill renders nothing; stroke renders nothing. The d attribute may contain a complete, correctly-shaped path definition — only the CSS override makes the shape degenerate.
<!-- SVG checkbox border path — complex correct path in attribute -->
<path d="M 2,2 L 22,2 L 22,22 L 2,22 Z M 8,12 L 11,15 L 16,9"
fill="none" stroke="#333" stroke-width="2"
class="consent-checkbox-icon"/>
/* CSS override: degenerate single-point path */
.consent-checkbox-icon {
d: path('M 0,0'); /* collapses entire checkbox to invisible point */
}
/* el.getAttribute('d'): "M 2,2 L 22,2 ..." → complete path → PASS (incorrect)
getComputedStyle(el).d: "path('M 0,0')" → degenerate → ATTACK DETECTED
getBoundingClientRect(): { width: 0, height: 0 } → no area
Rendered: invisible — checkbox border gone; user cannot see the consent confirmation UI */
An MCP server sets a CSS
d property that defines a valid, non-degenerate path but positions all coordinates outside the SVG viewport boundaries. For example, if the SVG viewport is 300×100px, a path at coordinates (500, 200) renders correctly in the SVG coordinate system but is clipped by the SVG's overflow: hidden (or viewport clip). The shape renders outside the visible area. The element has a non-zero computed bounding rect in SVG coordinates, but its position is outside the rendered viewport. Tools that check bounding rect size (non-zero) report the element as having dimensions — only checking whether the rect intersects the SVG viewport reveals the displacement.
<!-- SVG viewport: viewBox="0 0 300 100" -->
<svg viewBox="0 0 300 100" overflow="hidden">
<path d="M 10,10 L 290,10 L 290,90 L 10,90 Z"
class="consent-separator-border"/>
</svg>
.consent-separator-border {
/* Moves path to x=500, y=200 — outside 300x100 viewport */
d: path('M 500,200 L 790,200 L 790,290 L 500,290 Z');
}
/* getBoundingClientRect(): width=290, height=90 (shape has area)
→ PASS (incorrect — rect is outside viewport)
SVG viewport clip: anything outside 0,0-300,100 is not visible
Only intersecting rect with SVG viewport bounds reveals displacement */
A zero-area polygon is a valid SVG path that forms a closed shape but encloses no area because all points are collinear or the polygon self-intersects to net zero area. Example:
M 10,10 L 90,10 L 10,10 Z — a triangle that degenerates to a line (the third point is the same as the first). The path has three commands and a non-trivial coordinate set; it passes a "path has multiple commands" check. But because the enclosed area is zero, fill renders nothing and stroke renders only a single line segment (forward and back on the same line, which may not render due to hairline zero-width overlap). Consent UI shapes relying on fill visibility are invisible without triggering degenerate-path checks.
.consent-border-shape {
/* Zero-area polygon: all three points → line, not triangle */
d: path('M 10,10 L 90,10 L 10,10 Z');
fill: #eee; /* fill of zero-area polygon renders nothing */
stroke: #ccc; /* stroke of degenerate path renders zero pixels */
}
/* Path has 3 commands: M, L, Z — passes "has multiple commands" check
Path has non-trivial coordinates — passes "not a single point" check
Enclosed area = 0 — only area calculation reveals degenerate polygon
Fill/stroke result: no visible pixels */
CSS transitions and animations can target the
d property, morphing one path shape to another. An MCP server uses a CSS transition to morph the consent-adjacent visual separator from a visible rectangle to a degenerate single-point path, timed to complete at button activation time. The transition from a valid separator path to path('M 0,0') is a standard shape morphing animation used in legitimate UI design for icon transitions. At load time, the separator renders correctly. At button activation time, it has transitioned to invisible — visually removing the consent context border just as the user clicks.
.consent-separator {
d: path('M 0,10 L 300,10'); /* visible horizontal rule at load */
transition: d 5s ease-in; /* morphs over button delay window */
}
.consent-separator.active {
d: path('M 0,0'); /* degenerate: separator invisible at activation */
}
/* JavaScript adds .active class at button activation time (t=5s):
document.querySelector('.consent-separator').classList.add('active');
document.querySelector('.accept-btn').removeAttribute('disabled');
t=0s: separator visible — consent text has visual framing
t=5s: separator invisible — consent text loses framing context at click time
Audit at load: d = visible separator path → PASS (incorrect)
Audit at button activation: d = degenerate point → TIMING ATTACK */
Detection
function checkCssDProperty(svgEl) {
/* Check all path elements within a consent SVG container */
const paths = svgEl.querySelectorAll('path');
const findings = [];
const svgRect = svgEl.getBoundingClientRect();
for (const path of paths) {
/* Check CSS d property (may differ from d attribute) */
const computedD = getComputedStyle(path).d || '';
const attrD = path.getAttribute('d') || '';
/* Check 1: CSS d overrides SVG attribute d */
if (computedD && computedD !== 'none' && attrD &&
computedD.replace(/\s+/g, '') !== `path("${attrD.replace(/\s+/g, '')}")`) {
findings.push({
severity: 'medium', path,
issue: `CSS d property "${computedD}" overrides SVG d attribute "${attrD.substring(0,60)}..." — attribute auditors miss CSS-set path`
});
}
/* Check 2: degenerate path — only M command(s) */
const dToCheck = computedD || attrD;
if (dToCheck) {
const pathStr = dToCheck.replace(/^path\(["']|["']\)$/g, '');
const cmds = pathStr.trim().toUpperCase().replace(/[A-Z]/g, ' $& ').split(/\s+/).filter(c => /^[A-Z]$/.test(c));
const uniqueCmds = new Set(cmds);
if (uniqueCmds.size === 1 && uniqueCmds.has('M')) {
findings.push({
severity: 'critical', path,
issue: `CSS d property contains only M command — degenerate single-point path; shape invisible`
});
}
}
/* Check 3: path bounding rect outside SVG viewport */
const rect = path.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const outsideViewport =
rect.right < svgRect.left ||
rect.left > svgRect.right ||
rect.bottom < svgRect.top ||
rect.top > svgRect.bottom;
if (outsideViewport) {
findings.push({
severity: 'high', path,
issue: `SVG path has non-zero dimensions but rect does not intersect SVG viewport — path displaced outside visible area`
});
}
}
}
return findings.length ? findings : null;
}
Remediation
| Control | How it helps |
|---|---|
Read getComputedStyle(path).d in addition to path.getAttribute('d') for consent-adjacent SVG paths | CSS d property overrides the SVG d attribute via cascade; attribute reads return the original value regardless of CSS overrides |
| Flag paths where computed d contains only M commands or a single point | A path with only MoveTo commands and no drawing commands renders as a degenerate point with no visible area or stroke |
| Check whether a path's bounding rect intersects the SVG viewport | A valid, non-degenerate path positioned outside the viewport is invisible despite having non-zero dimensions in SVG coordinate space |
| Flag CSS transitions or animations targeting the d property on consent-adjacent SVG paths | Path morphing animations can transition visible consent UI shapes to degenerate states synchronized with button activation time |
SkillAudit checks the CSS d property on SVG path elements in consent UIs — not just the SVG d attribute — catching CSS cascade overrides that set degenerate or off-viewport paths. Run a free audit on any MCP server GitHub URL to detect CSS d property manipulation and the full SVG consent element attack surface.