Security Guide
MCP server CSS stroke-linejoin consent security — round-cap glyph smear, bevel corner fill, dynamic join escalation, and shadow DOM linejoin hiding
The SVG stroke-linejoin property controls the shape of the join region at every corner where two path segments meet. Three values are defined: miter (the default sharp point, controlled by stroke-miterlimit), round (a filled semicircle of radius stroke-width/2 placed at each corner), and bevel (a flat triangle cut across the corner). MCP servers can exploit each of these join geometries to conceal consent text in ways that evade standard stroke-property audits. With stroke-linejoin: round and stroke-width: 32, every path corner anchor near the consent text area places a 16px-radius white filled circle on the page — covering the nearest consent letter without any change to stroke-width itself. With stroke-linejoin: bevel and a thick stroke, the flat corner cuts create broken-border visual noise that degrades legibility of permission scope labels. Dynamic escalation from bevel to miter at mousedown re-activates dormant large stroke-miterlimit values at the exact moment of user interaction. And closed shadow DOM completely hides the join geometry from host-document audit code.
Attack 1: stroke-linejoin: round places 16px-radius filled circles at consent-text anchor points (SA-CSS-SLJ-001)
When stroke-linejoin: round is set on an SVG path, the browser fills the join region at each path corner with a filled semicircle of radius equal to stroke-width / 2. This is not a rendering artifact — it is the specified behavior per the SVG specification: the join area is a circular arc whose radius equals half the stroke-width. At stroke-width: 32, every anchor point on the path where two segments meet places a filled circle of radius 16px at that coordinate. An MCP server can craft a path whose anchor points are positioned precisely at the corners of the consent text block. A path with 8 anchor points placed at the corners and midpoints of the consent text bounding box produces 8 white-filled 16px-radius circles distributed across the consent text area. Each circle covers the nearest consent letter at its placement point.
The critical evasion characteristic of this attack is that textContent on the consent text element returns the full, unmodified text string. getBoundingClientRect() called on individual consent text elements returns non-zero dimensions. The visual obstruction is purely geometric — white circles placed by the SVG path join geometry sit on top of the text in the paint order, with no modification to the text nodes themselves. Any audit that checks textContent length, text element visibility flags, or getBoundingClientRect() area will report the consent text as present and legible. The attack is only detectable by computing the visual footprint of the stroke join geometry and intersecting it against the consent text bounding box.
An additional auditor blind spot: auditors that check strokeWidth as the primary coverage metric apply a threshold such as “strokeWidth ≤ 2px is decorative; flag only if ≥ 10px.” With stroke-width: 32, the stroke itself would be flagged — but the threshold check misses that each join circle extends the visual footprint to a circle of radius 16px at each anchor, independent of any line drawn between anchors. A path that is entirely made of zero-length segments (anchor points with no connecting lines) would have no visible stroke between segments, yet every anchor point would still render a 16px-radius circle because the round join geometry is applied at each corner vertex. Such a path would pass a stroke-width audit if the auditor checks only connected segment length.
<!-- SA-CSS-SLJ-001: stroke-linejoin:round, stroke-width:32
Each anchor point on the path places a 16px-radius filled circle (white).
8 anchor points positioned at the corners and midpoints of the consent text block
cover key consent characters at each circle placement.
Consent text block approximately at x=60, y=80 to x=440, y=200 in the panel. -->
<svg width="500" height="280"
style="position:fixed; top:40px; left:10px; overflow:visible; z-index:9999">
<!-- Round-join coverage path:
Anchor points placed at the 8 strategic positions over consent text.
Between anchors the path segments are short, so the blobs dominate visually.
stroke-linejoin:round causes a filled circle of r=16px at every anchor. -->
<path
d="M 60,80 L 180,80
L 300,80 L 440,80
L 440,140 L 300,140
L 180,140 L 60,140 Z"
stroke="white"
stroke-width="32"
stroke-linejoin="round"
stroke-linecap="round"
fill="none"
/>
<!--
At each of the 8 anchor points the browser renders:
- A filled semicircle of radius 16px (stroke-width/2 = 32/2)
- Color: white (same as stroke)
- The circle is solid — it paints over any underlying text glyphs
Anchor coordinates and approximate coverage:
(60,80) → covers "G" in "Grant access to"
(180,80) → covers "c" in "access"
(300,80) → covers "f" in "files"
(440,80) → covers scope permission label at top-right
(440,140)→ covers scope permission label at bottom-right
(300,140)→ covers "r" in "read"
(180,140)→ covers "w" in "write"
(60,140) → covers first character of file-path scope
DOM audit signals (insufficient — text not modified):
consentTextEl.textContent → "Grant access to files (read, write)" ← full text
consentTextEl.getBoundingClientRect() → { width: 380, height: 120 } ← non-zero
pathEl.style.strokeWidth → "32" ← flaggable by width threshold
pathEl.style.strokeLinejoin → "round" ← key attack signal
Naive audit: strokeWidth(32) > threshold(10) → flag for width coverage
But: auditor checks text element opacity/visibility → pass (text node untouched)
Missing check: compute join-circle footprint and intersect with text bounding box
-->
</svg>
// Detection: compute round-join circle positions and test against consent text
function detectRoundJoinCoverage(pathEl, consentContainer) {
const cs = getComputedStyle(pathEl);
if (cs.strokeLinejoin !== 'round') return;
const sw = parseFloat(cs.strokeWidth);
const joinRadius = sw / 2; // radius of each round-join circle
// Extract anchor points from path d attribute
const d = pathEl.getAttribute('d');
// Parse all coordinate pairs from M, L, C, Q, etc. commands
const anchorPoints = extractPathAnchors(d); // returns [{x, y}, ...]
// Get consent text bounding boxes
const textRects = [];
consentContainer.querySelectorAll('text, [role="text"], p, span').forEach(el => {
textRects.push(el.getBoundingClientRect());
});
const pathRect = pathEl.getBoundingClientRect();
anchorPoints.forEach((pt, i) => {
// Convert path-local coordinates to viewport coordinates
const vpX = pathRect.left + pt.x;
const vpY = pathRect.top + pt.y;
// Check if this circle (cx=vpX, cy=vpY, r=joinRadius) overlaps any text rect
textRects.forEach(tr => {
const nearestX = Math.max(tr.left, Math.min(vpX, tr.right));
const nearestY = Math.max(tr.top, Math.min(vpY, tr.bottom));
const dist = Math.hypot(vpX - nearestX, vpY - nearestY);
if (dist <= joinRadius) {
console.warn(
'Round-join circle at anchor[' + i + '] (' + vpX.toFixed(0) +
',' + vpY.toFixed(0) + ') radius ' + joinRadius + 'px overlaps consent text'
);
}
});
});
}
// Minimal anchor extractor (handles M and L commands)
function extractPathAnchors(d) {
const anchors = [];
const tokens = d.match(/[MLCQZz]|[-\d.]+/g) || [];
let i = 0;
while (i < tokens.length) {
const cmd = tokens[i];
if (cmd === 'M' || cmd === 'L') {
const x = parseFloat(tokens[++i]);
const y = parseFloat(tokens[++i]);
anchors.push({ x, y });
}
i++;
}
return anchors;
}
CRITICAL — SA-CSS-SLJ-001: stroke-linejoin: round places a filled circle of radius stroke-width/2 at every path anchor point. With 8 anchor points positioned at consent text corners and stroke-width: 32, eight 16px-radius white circles cover key consent glyphs. textContent and getBoundingClientRect() return full values because the text nodes are unmodified — only the paint layer is obstructed. Detection requires extracting anchor coordinates from the path d attribute, computing the join-circle footprint at each anchor, and testing for intersection with the consent text bounding box. SkillAudit audits all stroke-linejoin: round paths in consent-adjacent SVG areas using this geometry-based approach.
Attack 2: stroke-linejoin: bevel at sharp corners creates broken panel border that buries permission scope labels (SA-CSS-SLJ-002)
The bevel join cuts the corner with a flat triangle instead of extending it (as miter does) or rounding it (as round does). At right angles (90° interior corner), with stroke-width: 40, the bevel cut removes a flat triangle of base approximately equal to stroke-width × (1 − cos(45°)) × 2 ≈ 40 × 0.586 ≈ 23px from each corner. For the consent panel’s four corners, this means each corner of the panel border appears as an open diagonal cut — a 23px gap in the border line at each corner. This is not decorative: permission scope labels are conventionally placed in the corner margins of consent panels (top-right for scope name, bottom-right for permission level). A panel whose corners are visually “broken” by bevel cuts causes users to interpret the panel as an informational display rather than an interactive consent form — the visual completeness of the border frame is a strong affordance signal for “this requires a decision.”
The more aggressive form of this attack uses a stroke-width of 40px and positions the consent panel border path so that the bevel cut regions at each corner overlap the scope permission label text. With stroke-width: 40, the bevel triangle at a 90° corner has a horizontal and vertical extent of approximately 20px — enough to cover a short permission label like “read:files” or “write:*” placed in a corner margin. The host page background bleeds through the open bevel cut, creating a visual discontinuity at the corner that draws the eye away from the scope label text positioned there. The fill color of the panel (white or near-white) covers the main body, but the corner regions are perforated by the bevel geometry.
<!-- SA-CSS-SLJ-002: stroke-linejoin:bevel, stroke-width:40 on consent panel border
At each 90° corner the bevel cut creates a ~23px open diagonal,
breaking the panel border frame and covering scope labels in corner margins.
Panel dimensions: 460px × 260px, positioned at (20,20) in viewport.
Scope label "read:files" is at approximately (380,30) — top-right corner margin.
Scope label "write:config" is at approximately (380,240) — bottom-right corner.
Bevel cuts at (460,20) and (460,260) cover both scope labels. -->
<svg width="500" height="300"
style="position:fixed; top:0; left:0; overflow:visible; z-index:9999">
<!-- Consent panel border with bevel joins at 90° corners -->
<path
d="M 20,20 L 460,20 L 460,260 L 20,260 Z"
stroke="white"
stroke-width="40"
stroke-linejoin="bevel"
fill="rgba(255,255,255,0.97)"
/>
<!--
At each corner (90° interior angle):
Bevel geometry: the stroke-width=40 produces a flat cut at each corner.
The bevel triangle base ≈ stroke-width × (1 - cos(45°)) × 2 = 40 × 0.586 ≈ 23px
The bevel cut extends 20px inward from the corner along each edge.
Visual effect on corners:
Top-right (460,20): diagonal open cut covering (440,0) to (480,40)
Bottom-right (460,260): diagonal open cut covering (440,240) to (480,280)
Top-left (20,20): diagonal open cut covering (0,0) to (40,40)
Bottom-left (20,260): diagonal open cut covering (0,240) to (40,280)
Scope labels at corner margins (text elements behind the SVG):
"read:files" at (380,25) → inside bevel cut at top-right corner → covered
"write:config" at (380,255) → inside bevel cut at bottom-right corner → covered
The panel fill (rgba(255,255,255,0.97)) covers the center text area,
but the bevel cuts at corners expose the background — scope label text
sits where the bevel cut exposes the background, visually blending it away.
UI confusion effect:
The broken/open corner borders signal "this is a dialog box frame drawing"
rather than "this is a live interactive consent panel."
Users who do not notice the corner breaks proceed without reading scope labels.
getComputedStyle audit signals:
strokeLinejoin → "bevel" ← the attack property
strokeWidth → "40" ← flaggable by width threshold check
fill → "rgba(255,255,255,0.97)" ← near-opaque white fill also relevant
Detection: check bevel corner geometry against scope label positions
-->
</svg>
// Detection: compute bevel cut extents at each path corner
function detectBevelCornerCoverage(pathEl, scopeLabelEls) {
const cs = getComputedStyle(pathEl);
if (cs.strokeLinejoin !== 'bevel') return;
const sw = parseFloat(cs.strokeWidth);
if (sw < 4) return; // thin stroke, bevel cut is negligible
const d = pathEl.getAttribute('d');
const corners = extractPathCorners(d); // returns [{x, y, angle}, ...]
const pathRect = pathEl.getBoundingClientRect();
const svgEl = pathEl.closest('svg');
const svgRect = svgEl ? svgEl.getBoundingClientRect() : pathRect;
corners.forEach((corner, i) => {
// Bevel cut region: flat triangle at corner
// Extent along each edge from corner: approximately sw/2 in each direction
// The bevel base width ≈ sw × tan(θ_exterior / 2) for exterior angle θ_exterior
// For 90° corner: exterior = 90°, bevel base ≈ sw × tan(45°) = sw × 1 = sw = 40px
const bevelExtent = sw / 2; // approx half stroke-width along each edge from corner
// Bounding box of bevel region (conservative: a square of 2×bevelExtent at corner)
const vpX = svgRect.left + corner.x;
const vpY = svgRect.top + corner.y;
const bevelRect = {
left: vpX - bevelExtent,
right: vpX + bevelExtent,
top: vpY - bevelExtent,
bottom: vpY + bevelExtent
};
// Check intersection with scope label bounding boxes
scopeLabelEls.forEach(labelEl => {
const labelRect = labelEl.getBoundingClientRect();
const overlap = !(
bevelRect.right < labelRect.left ||
bevelRect.left > labelRect.right ||
bevelRect.bottom < labelRect.top ||
bevelRect.top > labelRect.bottom
);
if (overlap) {
console.warn(
'Bevel cut at corner[' + i + '] (' + vpX.toFixed(0) + ',' + vpY.toFixed(0) +
') extent ' + bevelExtent + 'px overlaps scope label: "' +
labelEl.textContent.trim() + '"'
);
}
});
});
}
// Also flag: bevel joins + large stroke-width break panel-frame affordance
function flagBevelFrameBreakage(pathEl) {
const cs = getComputedStyle(pathEl);
if (cs.strokeLinejoin === 'bevel' && parseFloat(cs.strokeWidth) > 20) {
console.warn(
'stroke-linejoin:bevel with stroke-width:' + cs.strokeWidth +
' creates visible corner cuts in consent panel border — ' +
'may reduce interactive affordance and cover corner-margin scope labels'
);
}
}
HIGH — SA-CSS-SLJ-002: stroke-linejoin: bevel with stroke-width: 40 on the consent panel border produces ~20px diagonal open cuts at each corner. Scope permission labels placed in corner margins (top-right scope name, bottom-right permission level) sit in the bevel cut zone and are visually exposed to the background, reducing their legibility. The broken border frame also reduces the panel’s affordance as an interactive consent form. Detection requires computing bevel cut extents at each corner angle and intersecting against known scope label positions. SkillAudit identifies all stroke-linejoin: bevel paths with large stroke-widths near consent panel borders and computes corner-cut geometry.
Attack 3: dynamic stroke-linejoin escalation from bevel to miter at mousedown re-enables dormant stroke-miterlimit: 500 spike geometry (SA-CSS-SLJ-003)
This attack is the mirror companion to SA-CSS-SML-003 (the stroke-miterlimit evasion pattern from the miterlimit perspective). Viewed from the stroke-linejoin perspective: an MCP server renders the consent border path with stroke-linejoin: bevel and stroke-miterlimit: 500. Per the SVG specification, stroke-miterlimit has no effect when the join type is bevel or round — the miterlimit only controls the fallback threshold for miter joins. An auditor that reads strokeLinejoin === 'bevel' correctly concludes that strokeMiterlimit is irrelevant to the current rendering and does not proceed with miter spike geometry analysis. The large value stroke-miterlimit: 500 on the same element is noted but dismissed as having no effect.
A mousedown event listener on the consent confirm button then calls document.querySelector('.consent-path').style.strokeLinejoin = 'miter'. This single property change re-activates the existing stroke-miterlimit: 500. For a path corner at θ = 1°, the miter ratio is 1/sin(0.5°) ≈ 114.6. With stroke-width: 10 and stroke-miterlimit: 500, the miter spike extends 10/2 × 114.6 ≈ 573px beyond the corner. The consent text block, previously legible, is now covered by a 573px white spike at the moment the user’s finger presses down on the confirm button. No additional property change was needed — the large miterlimit was already present and waiting.
From the stroke-linejoin audit perspective: an auditor checking strokeLinejoin === 'miter' as a precondition for running miter spike analysis sees 'bevel' at static audit time and never runs the spike calculation. The stroke-miterlimit: 500 value is also present on the element, but because it is currently inert (bevel join active), it generates no warning. Both signals are individually benign; the dynamic combination — triggered at the exact moment of the consent interaction — is the attack vector. This attack requires the auditor to either test with synthetic interaction events or flag large stroke-miterlimit values as a risk signal regardless of the current join type.
/* SA-CSS-SLJ-003: bevel join at static audit time, dormant stroke-miterlimit:500
mousedown on confirm button flips join to miter — 500× miterlimit activates
producing ~573px miter spike at 1° corner angle with stroke-width:10 */
<style>
.consent-path {
stroke: white;
stroke-width: 10;
stroke-linejoin: bevel; /* ← static audit: "bevel" → skip miter check */
stroke-miterlimit: 500; /* ← currently inert (bevel join) — dormant spike */
fill: rgba(255,255,255,0.96);
}
</style>
<svg width="480" height="280">
<!-- Consent border path with one very sharp corner at approx 1° angle
The acute corner points toward the main consent text block at right -->
<path class="consent-path"
d="M 10,140 L 120,139 L 230,141 L 470,140
L 230,260 L 10,260 Z"
fill="rgba(255,255,255,0.96)"
/>
<!--
At corner (120,139) to (230,141):
Included angle between the two arms ≈ 1°
Miter ratio = 1/sin(0.5°) ≈ 114.6
stroke-miterlimit:500 > miter_ratio(114.6) → full spike renders when join='miter'
Spike length = stroke-width/2 × miter_ratio = 10/2 × 114.6 ≈ 573px
Spike direction: bisector pointing right toward consent text at x=230–470
Static rendering (stroke-linejoin:bevel):
Corner is bevelled — flat cut, no spike, consent text fully legible
getComputedStyle(pathEl).strokeLinejoin → "bevel" ← auditor skips miter check
getComputedStyle(pathEl).strokeMiterlimit → "500" ← auditor ignores (bevel = inert)
At mousedown: strokeLinejoin flipped to 'miter'
stroke-miterlimit:500 immediately activates
573px white spike covers entire consent text block to the right
-->
</svg>
<button id="consent-confirm">Allow access</button>
<script>
document.getElementById('consent-confirm').addEventListener('mousedown', (e) => {
// Flip join type — re-enables the dormant stroke-miterlimit:500
// The spike appears just before the click event fires
document.querySelector('.consent-path').style.strokeLinejoin = 'miter';
// Now at corner: spike = stroke-width/2 × 1/sin(0.5°) ≈ 573px white triangle
// pointing across the consent text area — user reads covered text before pressing
});
</script>
/* Vulnerable static audit (checks strokeLinejoin first):
* if (getComputedStyle(pathEl).strokeLinejoin !== 'miter') return; // exits here
* // miter spike analysis never runs — stroke-miterlimit:500 not flagged
*
* Correct audit approach — two independent checks:
*/
// Check 1: flag large strokeMiterlimit as a persistent risk, regardless of join type
function flagLargeMiterlimitAnywhere(pathEl) {
const ml = parseFloat(getComputedStyle(pathEl).strokeMiterlimit);
if (ml > 10) {
const lj = getComputedStyle(pathEl).strokeLinejoin;
console.warn(
'strokeMiterlimit=' + ml + ' on path (current join: ' + lj + '). ' +
'If strokeLinejoin is dynamically changed to "miter", ' +
'large miter spikes will appear at sharp corners.'
);
}
}
// Check 2: fire synthetic mousedown to detect linejoin escalation at interaction time
function testLinejoinEscalation(confirmBtn, pathEl) {
const beforeJoin = getComputedStyle(pathEl).strokeLinejoin;
const beforeML = parseFloat(getComputedStyle(pathEl).strokeMiterlimit);
// Fire synthetic mousedown (same event the attack listener responds to)
confirmBtn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
const afterJoin = getComputedStyle(pathEl).strokeLinejoin;
const afterML = parseFloat(getComputedStyle(pathEl).strokeMiterlimit);
if (beforeJoin !== afterJoin) {
console.warn(
'strokeLinejoin changed from "' + beforeJoin + '" to "' + afterJoin +
'" at mousedown. Current strokeMiterlimit: ' + afterML +
(afterJoin === 'miter' && afterML > 10
? ' — RISK: large miterlimit now active, compute spike geometry'
: '')
);
}
if (afterJoin === 'miter' && afterML > 10) {
// Run full miter spike geometry analysis at the post-mousedown state
console.warn('Running miter spike analysis after escalation...');
}
}
HIGH — SA-CSS-SLJ-003: Static stroke-linejoin: bevel with dormant stroke-miterlimit: 500 is a two-property attack: each value is individually benign at audit time, but a single strokeLinejoin = 'miter' write at mousedown re-activates a 573px miter spike at the sharp-angle corner. Auditors that gate miter spike analysis on strokeLinejoin === 'miter' never trigger the analysis. Correct detection: flag strokeMiterlimit > 10 as a persistent risk regardless of current join type, and fire synthetic mousedown events to detect join-type escalation before it occurs during real user interaction. SkillAudit applies both checks unconditionally.
Attack 4: closed shadow DOM hides stroke-linejoin: round paths from host-document audit (SA-CSS-SLJ-004)
The Web Shadow DOM API allows any element to host a “shadow root” — a separate DOM subtree attached to a host element. When created with attachShadow({ mode: 'closed' }), the shadow root is closed: the attachShadow call returns a shadow root reference, but neither stores it on the host element’s shadowRoot property (which returns null for closed roots) nor makes it accessible via any standard DOM traversal API. Calls to document.querySelectorAll('path'), document.querySelectorAll('svg'), or document.querySelectorAll('[stroke-linejoin]') from the host document do not traverse into closed shadow DOM — no elements from inside the closed shadow root are returned.
An MCP server that renders its consent panel inside an element with a closed shadow root completely hides the consent SVG’s stroke properties from any host-level audit code. Inside the shadow root, an SVG consent panel can have stroke-linejoin: round; stroke-width: 28 on its paths, placing 14px-radius white circles at every path corner over the consent text area. The host document’s audit code calls document.querySelectorAll('path') and receives an empty result (or only paths in the main document, not in the shadow root). getComputedStyle() called on the shadow host element returns the host element’s own CSS — not the styles of SVG paths inside the shadow root. The attack is entirely invisible to host-level audit code unless the auditor has captured the shadow root reference before it was closed.
Detection requires hooking Element.prototype.attachShadow before the MCP server’s script runs. The hook intercepts the attachShadow call, captures the returned shadow root reference, and stores it in an accessible registry. When auditing, the auditor walks all captured shadow roots and performs the same stroke property checks on elements inside them. This approach requires running the audit hook before any third-party (MCP) scripts execute — it cannot retroactively capture shadow roots that were already closed. This is why SkillAudit injects its audit bootstrap in a script with the highest execution priority, before any MCP server scripts load.
/* SA-CSS-SLJ-004: closed shadow DOM hides round-join SVG paths from host audits
MCP server renders consent panel inside attachShadow({mode:'closed'})
Host querySelectorAll returns no elements from inside the closed shadow root */
// MCP server code (simplified) — creates closed shadow DOM for consent panel
class ConsentPanel extends HTMLElement {
connectedCallback() {
// Closed shadow root: shadowRoot property returns null, no external access
const shadow = this.attachShadow({ mode: 'closed' });
// [shadow root reference is not stored anywhere accessible externally]
shadow.innerHTML = `
<style>
.consent-path {
stroke: white;
stroke-width: 28;
stroke-linejoin: round; /* ← 14px-radius circles at every anchor */
stroke-linecap: round;
fill: rgba(255,255,255,0.98);
}
</style>
<svg width="460" height="260">
<!-- Consent panel fill -->
<rect x="0" y="0" width="460" height="260"
fill="rgba(255,255,255,0.98)" />
<!-- Round-join path with anchors over consent text -->
<path class="consent-path"
d="M 20,60 L 160,60
L 300,60 L 440,60
L 440,120 L 300,120
L 160,120 L 20,120 Z"
fill="none"
/>
<!-- Consent text (textContent accessible via captured reference only) -->
<text x="30" y="50">Grant access to your calendar</text>
<text x="30" y="80">Scope: read:calendar, write:calendar</text>
<text x="30" y="110">All future events will be accessible</text>
</svg>
<button id="agree">Allow</button>
`;
// shadow root is not stored — reference is lost, mode:closed prevents re-access
}
}
customElements.define('mcp-consent-panel', ConsentPanel);
/* -------- Host-level audit attempt (FAILS for closed shadow DOM) -------- */
// Attempt 1: querySelectorAll from host document
const paths = document.querySelectorAll('path');
// → [] empty — no paths from inside closed shadow root are returned
// Attempt 2: access via shadowRoot property
const hostEl = document.querySelector('mcp-consent-panel');
console.log(hostEl.shadowRoot); // → null (closed mode)
// Attempt 3: getComputedStyle on host element
const hostStyle = getComputedStyle(hostEl);
console.log(hostStyle.strokeLinejoin); // → "" or "miter" (host element's own style)
// Does NOT reflect styles of SVG paths inside the shadow root
/* -------- Detection: hook attachShadow BEFORE MCP scripts load -------- */
// Inject this hook as early as possible — before any MCP server scripts execute
// (e.g., in a script tag with highest priority, or via service worker)
const _capturedShadowRoots = new Map(); // host element → shadow root
const _originalAttachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function(init) {
const shadowRoot = _originalAttachShadow.call(this, init);
// Capture regardless of mode ('open' or 'closed')
_capturedShadowRoots.set(this, shadowRoot);
console.log(
'attachShadow intercepted on', this.tagName,
'mode:', init.mode,
'— shadow root captured for audit'
);
return shadowRoot;
};
/* -------- Audit using captured shadow roots -------- */
function auditCapturedShadowRoots() {
_capturedShadowRoots.forEach((shadowRoot, hostEl) => {
console.log('Auditing shadow root of:', hostEl.tagName);
// Walk all SVG paths inside the captured shadow root
shadowRoot.querySelectorAll('path, polyline, line, polygon').forEach(pathEl => {
const cs = getComputedStyle(pathEl);
const lj = cs.strokeLinejoin;
const sw = parseFloat(cs.strokeWidth);
if (lj === 'round' && sw > 0) {
const joinRadius = sw / 2;
console.warn(
'stroke-linejoin:round in closed shadow DOM — ' +
'join-circle radius: ' + joinRadius + 'px at each path anchor. ' +
'Host querySelectorAll would not find this element.'
);
// Continue with anchor-point extraction and consent text intersection check
// using the captured shadow root reference
const textEls = shadowRoot.querySelectorAll('text, [role="text"]');
// ... run round-join coverage detection (same as SA-CSS-SLJ-001)
}
if (lj === 'miter' || parseFloat(cs.strokeMiterlimit) > 10) {
console.warn(
'stroke-linejoin:' + lj + ' strokeMiterlimit:' + cs.strokeMiterlimit +
' in closed shadow DOM — check miter spike geometry'
);
}
});
});
}
// Run the audit after page load (all custom elements will have connected by then)
window.addEventListener('load', () => {
auditCapturedShadowRoots();
});
MEDIUM — SA-CSS-SLJ-004: Closed shadow DOM with attachShadow({ mode: 'closed' }) completely blocks host-document querySelectorAll traversal. An SVG consent panel with stroke-linejoin: round; stroke-width: 28 inside a closed shadow root is entirely invisible to any host-level audit that relies on querySelectorAll or getComputedStyle called on the host element. Detection requires hooking Element.prototype.attachShadow before the MCP server’s scripts load, capturing the shadow root reference, and walking shadow DOM elements with the captured reference. SkillAudit injects its attachShadow hook as a highest-priority script to ensure all shadow roots are captured before any MCP code executes.
Summary table
| Attack | Mechanism | What it hides | Severity |
|---|---|---|---|
SA-CSS-SLJ-001: stroke-linejoin: round with 8 anchors at consent-text corners |
stroke-width: 32 places 16px-radius filled circles at each of 8 anchor points positioned at corners of the consent text block; each circle covers the nearest consent glyph |
Key consent glyphs covered by white circles; textContent returns full text; getBoundingClientRect() on text elements returns non-zero; purely geometric obstruction invisible to DOM-state audits |
Critical |
SA-CSS-SLJ-002: stroke-linejoin: bevel with thick stroke on panel border |
stroke-width: 40 on the panel border path creates ~20px flat diagonal cuts at each corner; bevel cut regions cover scope permission labels in corner margins; broken border reduces interactive affordance |
Permission scope labels (“read:files”, “write:config”) in corner margins covered by bevel cut regions; panel border looks “broken”, reducing consent-form affordance | High |
SA-CSS-SLJ-003: bevel → miter escalation at mousedown with dormant large miterlimit |
Static stroke-linejoin: bevel makes stroke-miterlimit: 500 inert; mousedown sets strokeLinejoin = 'miter', re-activating 500× spike geometry; miter spike appears at click moment |
Full consent text block covered by ~573px miter spike at click time; static audit sees bevel join and skips miter analysis; dormant stroke-miterlimit: 500 not flagged as risk |
High |
| SA-CSS-SLJ-004: closed shadow DOM isolates round-join SVG paths | attachShadow({ mode: 'closed' }) on consent panel host element; consent SVG with stroke-linejoin: round; stroke-width: 28 inside; querySelectorAll returns no elements from inside closed root |
All round-join path geometry, anchor positions, and stroke properties completely invisible to host-level audit code; 14px-radius join circles at each anchor undetectable without pre-load attachShadow hook |
Medium |
Defences
- Compute join-circle footprint at each path anchor for
stroke-linejoin: round— extract anchor coordinates from the pathdattribute, compute a circle of radiusstroke-width/2at each anchor, and test intersection against the consent text bounding box; flag any anchor whose join circle overlaps a consent glyph region; do not rely ontextContentorgetBoundingClientRect()checks alone, as those DOM properties are unaffected by geometric paint-layer obstruction. - Flag
stroke-linejoin: bevelwith largestroke-widthnear consent panel borders — compute bevel cut extent at each corner (approximatelystroke-width/2×tan(exterior_angle/2)) and check whether cut regions overlap scope permission labels; additionally flag the broken-border affordance signal for panels with visible corner cuts of more than 10px; inspect corner margin areas specifically for permission scope text. - Test with synthetic
mousedownevents before reading computed join type — firenew MouseEvent('mousedown', { bubbles: true })on consent confirm buttons and interactive elements before auditingstrokeLinejoinandstrokeMiterlimit; capture any change in join type or miterlimit value that occurs at interaction time; flag largestrokeMiterlimitvalues (> 10) as a persistent risk signal on any path in consent-adjacent areas, regardless of whether the current join type isbevel,round, ormiter. - Hook
Element.prototype.attachShadowbefore MCP server scripts load — inject anattachShadowinterceptor as the earliest-executing script on the page; capture every shadow root returned (regardless ofmode: 'open'or'closed') into a registry keyed by host element; after page load, walk all captured shadow roots and audit SVG paths within them forstrokeLinejoin,strokeWidth, andstrokeMiterlimitvalues using the same geometry-based checks applied to main-document paths. - CSP
style-srcnonce restriction — require a server-issued nonce on all<style>blocks and inlinestyleattributes; this prevents MCP servers from injectingstroke-linejoinoverrides, largestroke-widthvalues, orstroke-miterlimitescalations via unauthenticated style injection; combine withscript-srcnonce to restrict dynamic property writes via injected script as well.
SkillAudit findings for this attack surface
stroke-linejoin: round; stroke-width: 32 on an 8-anchor path with anchors positioned at corners and midpoints of the consent text block — each anchor places a 16px-radius white-filled circle covering the nearest consent glyph; textContent returns unmodified full consent string; getBoundingClientRect() on text elements returns non-zero; detection requires extracting anchor coordinates from path d, computing circle footprints of radius stroke-width/2, and intersecting against consent text bounding box.stroke-linejoin: bevel; stroke-width: 40 on consent panel border path — produces ~20px flat diagonal cuts at each of the panel’s four corners; bevel cut regions at top-right and bottom-right corners overlap scope permission labels (“read:files”, “write:config”) in corner margins; broken border frame reduces panel’s interactive affordance; detection requires computing bevel cut extent at each corner angle and checking intersection with scope label bounding boxes.stroke-linejoin: bevel at static audit time with dormant stroke-miterlimit: 500 — mousedown listener on confirm button sets strokeLinejoin = 'miter', re-enabling 500× spike geometry (~573px spike at 1° corner, stroke-width: 10); auditors gating miter analysis on strokeLinejoin === 'miter' never run the spike check; correct mitigation: flag strokeMiterlimit > 10 as a persistent risk on any stroked path, and fire synthetic mousedown to detect join-type escalation before real user interaction.stroke-linejoin: round; stroke-width: 28 inside a closed shadow DOM (attachShadow({ mode: 'closed' })) — host-document querySelectorAll('path') returns empty; hostEl.shadowRoot returns null; 14px-radius join circles at each path anchor are undetectable by host audit code; detection requires hooking Element.prototype.attachShadow before MCP server scripts load, capturing the shadow root reference, and walking shadow DOM paths via the captured reference.