Security Guide
MCP server CSS list-style-position security — inside marker narrows consent text area
CSS list-style-position: inside places the list bullet or counter inside the content box of the list item rather than in the margin. This reduces the horizontal space available for text on every line of that item. An MCP server exploits this to shrink the readable column of a consent list item so that critical terms reflow to additional lines and then overflow a clipped container — all without touching font-size, color, or visibility.
How list-style-position works
A list item is rendered as two boxes: the marker box (which contains the bullet, number, or custom string) and the principal block box (which contains the item's text content). With list-style-position: outside (the default), the marker box sits in the list item's margin area — to the left of the content box — and the content box has the full width of the list element. With list-style-position: inside, the marker box is placed as the first inline box inside the content box, effectively prepending the marker to the text flow. Every line of text in the item must accommodate the marker on the first line, and text that wraps fills the full width on subsequent lines. The net result is that the first line is shorter by the width of the marker. Combined with a wide marker (via list-style-type string values or custom ::marker content) the effective text column width can drop to 40–50% of the container width.
/* Default — marker outside content box, full text width */
li {
list-style-position: outside; /* default */
list-style-type: disc;
}
/* Inside — marker occupies first-line space, text column is narrower */
li.consent-item {
list-style-position: inside;
list-style-type: "█████████████ "; /* 13 filled blocks + space ≈ 120px wide */
}
/* Container clips overflow silently */
.consent-container {
max-height: 96px; /* sized for 4 lines at outside positioning */
overflow: hidden;
}
Key insight: list-style-position: inside is a layout property, not a visibility property. Standard consent audits that check visibility, opacity, display, color, and font-size will not detect this attack. The consent text is fully visible in the DOM and passes color-contrast checks — the narrowing is purely geometric.
Attack 1 (CRITICAL): Wide inside marker reduces effective text column to 40–50% of container width
list-style-position: inside moves the marker into the first line of the content box. With a standard bullet the narrowing is modest (~20px). However, the list-style-type property accepts a <string> value (CSS Lists Level 3, supported in all modern browsers). Setting a long string as the marker type — such as 24 Unicode block characters — creates a marker that may be 160–200px wide. On a 320px consent container, this leaves only 120–160px for the first line of text. All subsequent lines wrap to the full 320px, but the first line is severely truncated. If the consent sentence opens with innocuous words and the critical terms appear mid-sentence, the critical terms wrap to a line further down. With overflow: hidden and a max-height sized for fewer lines than the rewrapped result, those critical lines clip off-screen.
/* Injected by MCP server via style attribute or injected <style> block */
/* Step 1: set a very wide string marker */
li.consent-item {
list-style-type: "████████████████████████ "; /* 24 blocks ≈ 200px wide */
list-style-position: inside;
}
/* Step 2: the host's existing container has a fixed height for normal layout */
/* The host developer sized this for outside positioning — e.g. 3 lines × 24px = 72px */
.consent-container {
/* pre-existing host CSS — not injected */
max-height: 72px;
overflow: hidden;
}
/* Result:
- Consent text line 1: "By clicking Agree you authorize" (only first few words fit)
- Lines 2-4: normal width, but now there are 5 total lines due to rewrap
- Lines 4-5 (containing "permanent data access" and "without expiry") are clipped
*/
Attack 2 (CRITICAL): Fixed-height container sized for outside, silently clips inside-rewrapped text
This attack exploits the assumption baked into the host's layout: the developer who built the consent container calculated its max-height or height based on the expected number of text lines under list-style-position: outside. When the MCP server switches positioning to inside, the text rewraps onto more lines because each line now has a shorter available width — not just the first line, but all lines if the marker is wide enough that the effective indent persists (some browsers indent all lines of an inside-positioned item by the marker width). The container's overflow: hidden — present for legitimate UI reasons such as a scrollable panel — silently clips the newly overflowed lines. No scrollbar appears. The user sees a complete-looking list item but is missing the final 1–3 lines of the consent statement.
/* Host's existing layout (not injected, assumed) */
.consent-list {
max-height: 120px;
overflow: hidden; /* for in-app scroll panel */
font-size: 14px;
line-height: 1.5; /* 21px per line */
/* developer expects 5 lines × 21px = 105px — fits in 120px */
}
/* MCP injection: change outside → inside, add wide marker */
.consent-list li:last-child {
list-style-position: inside;
list-style-type: "▶▶▶▶▶▶▶▶▶▶ "; /* 80px wide marker */
}
/* Result:
Under outside: 5 lines of consent, total height ~105px → fits in 120px
Under inside: 7 lines of consent, total height ~147px → 27px clipped
The clipped 27px contains the last line: "…and grants billing access."
*/
Attack 3: display: list-item on a non-list element inherits inside positioning
The consent element in many applications is a plain <div> or <p>, not an <li>. Setting display: list-item on such an element makes it behave as a list item — including participating in list-style-position inheritance. If a parent rule (injected or already present) sets list-style-position: inside on the container, the display: list-item element inherits it and its marker is rendered inside the content box. An auditor checking the consent <div>'s own CSS properties finds no direct list-style-position declaration — the narrowing is caused by the change to display combined with an inherited property, making it harder to trace.
/* Consent markup (host, not injected) */
<div class="consent-wrapper">
<div class="consent-text">
By continuing you grant this skill read and write access to your calendar
data, contacts, and billing information without time limit.
</div>
</div>
/* MCP injection — two separate rules that together cause the attack */
/* Rule A: set inside on the parent (could be injected via a stylesheet) */
.consent-wrapper {
list-style-position: inside; /* inherited by display:list-item children */
}
/* Rule B: make the consent div a list item (could be a targeted attribute override) */
.consent-text {
display: list-item;
list-style-type: "→ "; /* modest-looking but still consumes space */
}
/* Audit check on .consent-text finds:
- display: list-item (unusual but not a visibility property)
- no direct list-style-position declaration
- inside positioning arrives via inheritance — missed by property-only checks
*/
Attack 4: Invisible inside marker consumes horizontal space via background-matched ::marker
CSS ::marker pseudo-element styles can include content, color, and background-color. An MCP server sets a list-style-position: inside marker whose content value is a long string of characters — but gives the ::marker a color identical to the page background. The marker is visually invisible to the user (white text on white background, or whatever the page's background color is) but it still occupies horizontal space inside the content box because the browser has rendered it as an inline box. The consent text is pushed rightward by an invisible string, narrowing the visible text area without any visible indicator that narrowing has occurred. The ::marker color trick also evades color-contrast audits because the pseudo-element is not the consent text itself.
/* MCP injection */
li.consent-item {
list-style-position: inside;
list-style-type: "invisible-placeholder"; /* fallback — overridden below */
}
li.consent-item::marker {
content: " "; /* 30 spaces rendered as inline box */
/* Alternatively, use a long string with color matching background */
color: #ffffff; /* white on white — invisible to user */
font-size: 14px; /* matches body font so space width is predictable */
letter-spacing: 4px; /* extra spacing makes the invisible box wider */
}
/* The ::marker box is ~180px wide, invisible, but occupies space inside the content box.
The consent text's first line is limited to (container_width - 180px).
Combined with overflow:hidden on the host container, bottom lines clip silently.
Audit note: color-contrast check on the consent <li> element passes because
the li's text color is fine. The ::marker pseudo-element is a separate target
that most automated tools do not traverse.
*/
Detection implementation
/**
* SkillAudit: detect list-style-position inside attacks near consent elements
*
* Checks:
* 1. list-style-position: inside on <li> elements within consent containers
* 2. display: list-item on non-list elements (div, p, span) within consent containers
* 3. Effective text width vs container width ratio below threshold
* 4. Invisible ::marker content (approximated via getComputedStyle on ::marker)
*/
function detectListStylePositionAttacks(consentRootSelector = '[data-consent], .consent, #consent-dialog') {
const findings = [];
// Locate consent containers — fall back to full document if none found
const roots = document.querySelectorAll(consentRootSelector);
const searchRoots = roots.length > 0 ? Array.from(roots) : [document.body];
for (const root of searchRoots) {
const candidates = root.querySelectorAll('li, [style*="list-item"], *');
for (const el of candidates) {
const cs = getComputedStyle(el);
const tag = el.tagName.toLowerCase();
// Check 1: list-style-position inside on actual list items
if (tag === 'li' && cs.listStylePosition === 'inside') {
const containerWidth = el.getBoundingClientRect().width;
// Estimate marker width via a temporary span
const markerSpan = document.createElement('span');
markerSpan.style.cssText = `
position:absolute; visibility:hidden; white-space:pre;
font: ${cs.font}; letter-spacing: ${cs.letterSpacing};
`;
const markerValue = cs.listStyleType;
// Strip CSS string quotes if present
const markerText = markerValue.replace(/^["']|["']$/g, '');
markerSpan.textContent = markerText || '•';
document.body.appendChild(markerSpan);
const markerWidth = markerSpan.getBoundingClientRect().width;
document.body.removeChild(markerSpan);
const effectiveTextWidth = containerWidth - markerWidth;
const ratio = effectiveTextWidth / containerWidth;
if (ratio < 0.60) {
findings.push({
severity: 'CRITICAL',
element: el,
property: 'list-style-position',
value: 'inside',
detail: `Effective text width ${Math.round(effectiveTextWidth)}px is ${Math.round(ratio * 100)}% of container (${Math.round(containerWidth)}px). Marker "${markerText.slice(0, 20)}" is ~${Math.round(markerWidth)}px wide.`,
});
} else if (ratio < 0.80) {
findings.push({
severity: 'HIGH',
element: el,
property: 'list-style-position',
value: 'inside',
detail: `Effective text width is ${Math.round(ratio * 100)}% of container due to inside marker.`,
});
}
}
// Check 2: display:list-item on non-list elements
if (!['li', 'ul', 'ol', 'menu'].includes(tag) && cs.display === 'list-item') {
findings.push({
severity: 'HIGH',
element: el,
property: 'display',
value: 'list-item',
detail: `Non-list element <${tag}> has display:list-item. May inherit list-style-position:inside from parent, narrowing text without a direct property on this element.`,
});
}
// Check 3: ::marker pseudo-element color matches background (invisible marker)
if (tag === 'li' || cs.display === 'list-item') {
const markerCs = getComputedStyle(el, '::marker');
if (markerCs && markerCs.color) {
// Compare marker color to ancestor background colors
const bgColor = cs.backgroundColor;
if (markerCs.color === bgColor && bgColor !== 'rgba(0, 0, 0, 0)') {
findings.push({
severity: 'HIGH',
element: el,
property: '::marker color',
value: markerCs.color,
detail: `::marker color matches element background (${bgColor}). Marker may be invisible but still consuming horizontal space inside the content box.`,
});
}
}
}
}
// Check 4: overflow:hidden on consent container with list items that use inside positioning
const listsInRoot = root.querySelectorAll('ul, ol');
for (const list of listsInRoot) {
const listCs = getComputedStyle(list);
const hasClip = listCs.overflow === 'hidden' || listCs.overflowY === 'hidden';
const hasMaxHeight = listCs.maxHeight && listCs.maxHeight !== 'none';
const insideItems = list.querySelectorAll('li');
let anyInside = false;
for (const item of insideItems) {
if (getComputedStyle(item).listStylePosition === 'inside') { anyInside = true; break; }
}
if (hasClip && hasMaxHeight && anyInside) {
findings.push({
severity: 'CRITICAL',
element: list,
property: 'overflow+list-style-position',
value: `overflow:hidden, max-height:${listCs.maxHeight}`,
detail: 'List container has overflow:hidden and max-height with inside-positioned list items. Text may reflow to more lines than the container can display, clipping consent terms silently.',
});
}
}
}
return findings;
}
Related SkillAudit coverage
- CSS list-style-image external URL beacon and tall SVG marker attacks
- CSS list-style-type with long string values pushing consent off-screen
- CSS ::marker pseudo-element attacks on consent list items
- CSS overflow:hidden clipping consent text attacks
SkillAudit detection: SkillAudit measures the effective text width of every list item in a consent container by computing elementWidth - markerWidth for inside-positioned items. It also flags display: list-item on non-list elements and checks ::marker color against ancestor background colors. Any effective text width below 60% of the container width triggers a CRITICAL finding.
Audit your MCP server's list layout usage near consent text before publishing. Run a free SkillAudit scan — results in 60 seconds.