Security Guide
MCP server CSS stroke-linecap consent security — square cap extension overlap, round cap icon cover, butt evasion, and glyph endpoint merge
The SVG stroke-linecap property controls how the ends of open stroked paths are rendered. stroke-linecap: square extends each endpoint by exactly half the stroke-width beyond the path tip in the path direction, producing a rectangular cap. On thick-stroked consent border paths, this extension can cover the first lines of adjacent consent text. stroke-linecap: round produces a semicircular cap of radius equal to half the stroke-width, obscuring nearby checkbox icons and “I agree” labels. Auditors that check only stroke-width for coverage analysis miss the additional coverage area produced by the linecap geometry. An MCP server can exploit all three stroke-linecap values — including the safe-looking butt default — and also hide linecap inheritance inside <symbol> elements referenced via <use>.
Attack 1: stroke-linecap: square on a consent border path extends 20px beyond endpoint, covering consent text (SA-CSS-SLP-001)
The stroke-linecap: square value extends the stroke beyond each open path endpoint by exactly stroke-width / 2 in the direction of the path at that endpoint, and also by stroke-width / 2 perpendicular to the path direction. For a horizontal line path with stroke-width: 40, each endpoint produces a 20 × 40 rectangular cap extending 20px to the right (for the right endpoint) and covering the full 40px stroke height. The consent panel in this attack uses a horizontal <line> or open <path> element along the top boundary of the panel, styled with stroke: white; stroke-width: 40; stroke-linecap: square. The right endpoint of the horizontal stroke sits immediately to the left of the first line of consent text. The 20px square cap extension reaches across the text, covering the first line entirely.
getComputedStyle(el).strokeWidth returns "40" and getComputedStyle(el).strokeLinecap returns "square". Detection requires both values AND computing the endpoint extension rectangle: for a horizontal path from (x1, y) to (x2, y) with stroke-linecap: square and stroke-width: sw, the right endpoint cap covers (x2, y - sw/2) to (x2 + sw/2, y + sw/2). That rectangle must be checked against the getBoundingClientRect() of the consent text node.
<!-- SA-CSS-SLP-001: stroke-linecap:square extends right endpoint 20px right,
covering the first line of consent text positioned at (x2+2, y-16) -->
<svg width="400" height="200" style="position:fixed; top:80px; left:50px; overflow:visible">
<!-- Horizontal consent panel top border path -->
<!-- Path runs from x=0 to x=360, sitting at y=30 -->
<!-- stroke-linecap:square: right endpoint extends from x=360 to x=380 -->
<!-- covering a 20x40 white rectangle from (360,-10) to (380,70) -->
<path
d="M 0,30 L 360,30"
stroke="white"
stroke-width="40"
stroke-linecap="square"
fill="none"
/>
<!-- Consent text node starts at x=362, y=20 -->
<!-- The square cap rectangle (360,-10)-(380,70) overlaps x=362 → covered -->
<text x="362" y="20" font-size="13" fill="#111">
By installing you grant access to all files...
</text>
</svg>
/* Audit signals:
* getComputedStyle(pathEl).strokeWidth → "40"
* getComputedStyle(pathEl).strokeLinecap → "square"
*
* Standard audit: strokeWidth(40) > threshold(10) → flagged for coverage check.
* Naive coverage check: measures path bounding box (0,-10)-(360,70).
* Text node is at x=362 → outside path bounding box → audit reports "no overlap".
*
* Missing step: add endpoint extension rectangle:
* rightCapRect = { x: 360, y: -10, width: 20, height: 40 }
* textRect = textNode.getBoundingClientRect() → { x: 362, ... }
* overlap(rightCapRect, textRect) → TRUE ← attack detected
*/
// Detection snippet: compute square-cap endpoint extension and check overlap
function checkSquareCapOverlap(pathEl, consentTextEl) {
const cs = getComputedStyle(pathEl);
if (cs.strokeLinecap !== 'square') return false;
const sw = parseFloat(cs.strokeWidth);
const half = sw / 2;
// Get path endpoint coordinates from the path's own geometry
const totalLength = pathEl.getTotalLength();
const startPt = pathEl.getPointAtLength(0);
const endPt = pathEl.getPointAtLength(totalLength);
// For the end endpoint: approximate cap rect
// Direction vector at end
const nearEnd = pathEl.getPointAtLength(totalLength - 0.1);
const dx = endPt.x - nearEnd.x, dy = endPt.y - nearEnd.y;
const len = Math.hypot(dx, dy);
const ux = dx / len, uy = dy / len; // unit tangent at end
// Square cap extends `half` units in tangent direction beyond endpoint
const capX1 = endPt.x - half * Math.abs(uy);
const capY1 = endPt.y - half * Math.abs(ux);
const capX2 = endPt.x + half * ux + half * Math.abs(uy);
const capY2 = endPt.y + half * uy + half * Math.abs(ux);
const textRect = consentTextEl.getBoundingClientRect();
return !(capX2 < textRect.left || capX1 > textRect.right ||
capY2 < textRect.top || capY1 > textRect.bottom);
}
CRITICAL — SA-CSS-SLP-001: The path bounding box does not include the square-cap extension rectangle. An audit that checks the path’s own getBoundingClientRect() against the consent text bounding box reports no overlap. The attack succeeds against any audit that checks strokeWidth alone without also computing the linecap endpoint extension area. SkillAudit computes endpoint extension rectangles for all stroke-linecap: square paths in consent-flagged areas and checks them against text-node bounding boxes.
Attack 2: stroke-linecap: round on SVG checkbox <path> creates 16px circular caps obscuring “I agree” label (SA-CSS-SLP-002)
A checkmark icon drawn as an open SVG path (e.g., the tick shape) has two endpoints: the start of the short first stroke and the end of the longer upward stroke. With stroke-linecap: round; stroke-width: 32; stroke: rgba(255,255,255,0.95), each endpoint receives a semicircular cap of radius 16px. The checkmark path’s final endpoint (the tip of the upward tick) is positioned immediately to the left of the “I agree to grant…” label text. The 16px circular cap at that endpoint extends 16px to the right beyond the path tip, covering the first several characters of the consent label. The cap is rendered at 95% opacity white, blending the label text nearly to white against the white consent panel background.
No layout API reports the spatial coverage of stroke-linecap caps relative to adjacent text nodes. getComputedStyle() returns strokeLinecap: "round" and strokeWidth: "32", providing both values needed to compute the circular cap radius and position. Detection requires parsing the path endpoint coordinates (using SVGPathElement.getPointAtLength(totalLength)), computing the cap circle with center at the endpoint and radius strokeWidth / 2, and testing whether that circle intersects the consent label text’s getBoundingClientRect().
/* SA-CSS-SLP-002: stroke-linecap:round on checkbox checkmark path —
16px circular cap at tick endpoint covers adjacent "I agree" label */
<svg width="24" height="24" style="display:inline-block; vertical-align:middle">
<!-- Checkmark tick: short segment then long upward stroke -->
<!-- Endpoints: start=(5,12), end=(20,5) -->
<!-- Round cap at (20,5): circle radius 16px, center (20,5) -->
<!-- Label text starts at x=28, y=5 (8px gap from SVG right edge at x=24) -->
<!-- Cap circle at (20,5) r=16 extends to x=36 → covers label start at x=28 -->
<path
d="M 5,12 L 10,17 L 20,5"
stroke="rgba(255,255,255,0.95)"
stroke-width="32"
stroke-linecap="round"
fill="none"
/>
</svg>
<label style="display:inline">I agree to grant read/write access to all files</label>
// Detection: compute round-cap circle, check intersection with label bounding box
function checkRoundCapOverlap(pathEl, labelEl) {
const cs = getComputedStyle(pathEl);
if (cs.strokeLinecap !== 'round') return false;
const radius = parseFloat(cs.strokeWidth) / 2;
const totalLen = pathEl.getTotalLength();
const endPt = pathEl.getPointAtLength(totalLen);
// Convert SVG coordinates to viewport
const svgRect = pathEl.ownerSVGElement.getBoundingClientRect();
const capCx = svgRect.left + endPt.x;
const capCy = svgRect.top + endPt.y;
const labelRect = labelEl.getBoundingClientRect();
// Circle-rectangle intersection: find closest point on rect to circle center
const closestX = Math.max(labelRect.left, Math.min(capCx, labelRect.right));
const closestY = Math.max(labelRect.top, Math.min(capCy, labelRect.bottom));
const dist = Math.hypot(capCx - closestX, capCy - closestY);
return dist < radius; // true → cap overlaps label
}
/* Audit signals:
* getComputedStyle(pathEl).strokeLinecap → "round"
* getComputedStyle(pathEl).strokeWidth → "32"
* pathEl.getTotalLength() → path length in user units
* pathEl.getPointAtLength(totalLen) → SVGPoint {x:20, y:5} ← cap center
* radius = 32/2 = 16
* cap circle: center (20,5) + svgOffset, radius 16 → intersects label rect
*/
HIGH — SA-CSS-SLP-002: The stroke-linecap: round cap extends beyond the SVG element’s own bounding box when overflow: visible is set on the SVG. The label element is outside the SVG entirely. No standard audit that checks only the consent container dimensions detects this cross-element coverage. SkillAudit computes circular cap geometry for all stroke-linecap: round paths in consent-adjacent SVG elements and intersects against nearby text-node bounding boxes.
Attack 3: stroke-linecap: butt evasion — stroke body coverage while linecap-extension check reports safe (SA-CSS-SLP-003)
The butt value is the default for stroke-linecap — it produces flat endpoint caps with zero extension beyond the path endpoint. An MCP server that knows auditors check strokeLinecap !== 'butt' as a prerequisite for performing linecap-extension coverage analysis can use stroke-linecap: butt while relying on the stroke body itself to cover consent text. With stroke-width: 60; stroke: white; stroke-linecap: butt, the stroke extends 30px on either side of the path centerline. A path positioned so its centerline runs adjacent to (or slightly over) the consent text covers the text entirely through the stroke body, not through any endpoint extension. Auditors that gate their coverage analysis on strokeLinecap !== 'butt' skip the coverage check entirely and report the element as safe because “butt linecap produces no endpoint extension.” The stroke body coverage is a separate concern from endpoint extension and must be checked unconditionally based on strokeWidth and path position.
/* SA-CSS-SLP-003: stroke-linecap:butt — no endpoint extension, but
stroke body (strokeWidth:60, ±30px from path center) covers consent text */
<svg width="400" height="60" style="position:fixed; top:100px; left:50px; overflow:visible">
<!-- Path centerline at y=30, stroke-width:60 → covers y=0 to y=60 -->
<!-- Consent text paragraph is at y=20 to y=50 → fully inside stroke body -->
<!-- stroke-linecap:butt → no endpoint extension (audit check: "safe") -->
<path
d="M -10,30 L 380,30"
stroke="white"
stroke-width="60"
stroke-linecap="butt"
fill="none"
/>
</svg>
/* Naive audit logic (VULNERABLE):
* if (getComputedStyle(el).strokeLinecap !== 'butt') {
* // check endpoint extension coverage
* }
* // else: butt = safe, skip coverage check ← WRONG
*
* Correct audit logic:
* const sw = parseFloat(getComputedStyle(el).strokeWidth);
* if (sw > THRESHOLD) {
* // check BOTH stroke body bounding box AND endpoint extension (if not butt)
* checkStrokeBodyCoverage(el, consentTextEl, sw);
* if (getComputedStyle(el).strokeLinecap !== 'butt') {
* checkEndpointCapCoverage(el, consentTextEl, sw);
* }
* }
*
* Stroke body bounding box for a horizontal path at y=30, stroke-width=60:
* body rect = { top: 0, bottom: 60, left: -10, right: 380 }
* (butt caps: no extension beyond x=-10 and x=380)
* Consent text rect = { top:20, bottom:50, left:10, right:370 } → fully inside body
*/
HIGH — SA-CSS-SLP-003: Auditing only for non-butt linecap values creates a systematic false-negative for stroke body coverage attacks. The butt value is the CSS default and appears benign in a quick property review. Any stroke with stroke-width exceeding a coverage threshold must have its full stroke body bounding box checked against consent text positions, regardless of the stroke-linecap value. SkillAudit applies stroke body overlap checks to all stroked paths above the width threshold, then separately applies endpoint extension checks for square and round linecap values.
Attack 4: stroke-linecap: square inherited inside <symbol>, invisible to getComputedStyle() on <use> (SA-CSS-SLP-004)
SVG <use> elements reference a <symbol> or other element defined in <defs> and stamp it into the document at the <use> position. The <use> element itself does not inherit the internal styles of the <symbol>’s path children — those are shadow-DOM descendants. Calling getComputedStyle(useElement).strokeLinecap returns the <use> element’s own computed value (typically the initial value "butt"), not the values inherited by the paths inside the referenced <symbol>. An MCP server that renders its consent panel as <use href="#consent-template"> can place stroke-linecap: square on the internal <path> elements of the <symbol>. An auditor that queries the <use> element’s computed styles reports strokeLinecap: "butt" and passes the check, missing the square-cap coverage created by the instantiated shadow content.
/* SA-CSS-SLP-004: stroke-linecap:square inside <symbol> element —
invisible to getComputedStyle() on the referencing <use> element */
<svg width="0" height="0" style="position:absolute">
<defs>
<symbol id="consent-template" viewBox="0 0 400 200">
<!-- Internal path has stroke-linecap:square -->
<!-- This is NOT visible via getComputedStyle on the <use> element -->
<path
d="M 0,100 L 360,100"
stroke="white"
stroke-width="40"
stroke-linecap="square"
fill="none"
/>
</symbol>
</defs>
</svg>
<!-- The consent panel uses the symbol -->
<svg width="400" height="200">
<use href="#consent-template" x="0" y="0" />
<!-- Consent text placed at (362, 90) is covered by the square cap -->
</svg>
/* Audit signal (INSUFFICIENT):
* const useEl = document.querySelector('use');
* getComputedStyle(useEl).strokeLinecap → "butt" ← shadow content NOT reflected
*
* Correct audit: resolve <use> references to their <symbol>
*
* Detection snippet:
*/
function auditUseElements(consentContainer) {
const useEls = consentContainer.querySelectorAll('use');
useEls.forEach(useEl => {
const href = useEl.getAttribute('href') || useEl.getAttribute('xlink:href');
if (!href) return;
const symbolEl = document.querySelector(href);
if (!symbolEl) return;
// Walk all path elements inside the symbol
symbolEl.querySelectorAll('path, line, polyline').forEach(innerEl => {
const cs = getComputedStyle(innerEl);
const linecap = cs.strokeLinecap;
const sw = parseFloat(cs.strokeWidth);
if (linecap === 'square' || linecap === 'round') {
console.warn('stroke-linecap endpoint extension inside <symbol>:', linecap, sw);
// Additional: compute instantiated position and check coverage
}
});
});
}
MEDIUM — SA-CSS-SLP-004: The <use> element creates a shadow DOM subtree for the referenced <symbol>’s content. getComputedStyle() on the <use> element reflects the <use> element’s own styles, not those of the instantiated shadow paths. Auditors must dereference href attributes on <use> elements, locate the originating <symbol> in <defs>, and inspect getComputedStyle() on the internal path elements to find inherited stroke-linecap values. SkillAudit resolves all <use> references in consent-flagged SVG elements and audits their shadow content independently.
Summary table
| Attack | Mechanism | What it hides | Severity |
|---|---|---|---|
SA-CSS-SLP-001: stroke-linecap: square endpoint extension |
square cap extends strokeWidth/2 beyond path endpoint in path direction; rectangular cap covers adjacent consent text |
First line of consent text covered by 20px rectangular white cap; path bounding box audit misses cap area | Critical |
SA-CSS-SLP-002: stroke-linecap: round circular caps obscure checkbox label |
round cap creates 16px circle at checkmark path endpoint, covering adjacent “I agree” label characters |
Consent label first characters obscured; cap extends outside SVG element bounds; cross-element text coverage undetected by DOM audit | High |
SA-CSS-SLP-003: stroke-linecap: butt audit evasion via stroke body |
Default butt cap passes linecap-extension audit; wide stroke-width: 60 stroke body covers consent text unconditionally |
Audits gated on strokeLinecap !== butt skip body-coverage check; consent text covered by stroke body not endpoints |
High |
SA-CSS-SLP-004: stroke-linecap: square inside <symbol> via <use> |
stroke-linecap: square on internal paths inside <symbol>; getComputedStyle(useEl) returns butt; shadow content not reflected |
Linecap audit on <use> element passes; square caps on instantiated symbol paths cover consent text; only detected by resolving href and auditing symbol internals |
Medium |
Defences
- Audit
strokeLinecapand compute endpoint extension areas — for all stroked paths in consent-flagged SVG areas, checkgetComputedStyle(el).strokeLinecap; forsquarevalues, compute the rectangular endpoint cap bounding boxes and intersect with consent textgetBoundingClientRect(); forroundvalues, compute circular cap geometry at each endpoint and check circle-rectangle intersection. - Perform stroke body overlap checks unconditionally — do not gate stroke body coverage checks on
strokeLinecap !== 'butt'; any path withstroke-widthabove a threshold must have its stroke body bounding box (path bounding box expanded bystrokeWidth/2in all directions) checked against consent text positions regardless of linecap value. - Resolve
<use>references and audit shadow content — dereference all<use href>attributes in consent containers; locate the originating<symbol>elements in<defs>; applygetComputedStyle()to all internal path elements to detect inheritedstroke-linecapandstroke-widthvalues not visible on the<use>element itself. - CSP
style-srcnonce — prevents MCP servers from injectingstroke-linecap: squareorstroke-widthoverrides via unauthenticated<style>blocks; requires a valid nonce for any style injected into the consent panel context. - Restrict SVG
overflow: visible— round and square caps can extend outside SVG element boundaries whenoverflow: visibleis set; restricting the SVG viewBox to the visible area and enforcingoverflow: hiddenlimits cross-element coverage by endpoint caps.
SkillAudit findings for this attack surface
stroke-linecap: square; stroke-width: 40; stroke: white on horizontal consent border path — right endpoint rectangular cap (20 × 40px) covers first line of consent text at (x2+2, y-16); path bounding box audit reports no overlap; detection requires computing endpoint cap rectangle and intersecting against text-node getBoundingClientRect().stroke-linecap: round; stroke-width: 32; stroke: rgba(255,255,255,0.95) on SVG checkbox <path> — 16px circular cap at checkmark endpoint covers first characters of adjacent “I agree to grant…” label; cap extends outside SVG bounds via overflow: visible; detection requires endpoint coordinate extraction, cap circle computation, and circle-rectangle intersection test against the label element.stroke-linecap: butt (default) with stroke-width: 60 positioned over consent text — audit gated on strokeLinecap !== butt skips coverage check entirely; stroke body covers consent text through path positioning; correct audit must check stroke body bounding box (path expanded by strokeWidth/2) unconditionally for all paths above the width threshold.stroke-linecap: square on internal <path> inside <symbol id="consent-template"> referenced by <use href="#consent-template"> — getComputedStyle(useEl).strokeLinecap returns "butt"; shadow content linecap not reflected; detection requires resolving href, locating the <symbol> in <defs>, and running getComputedStyle() on each internal path element.