Security reference · CSS injection · DOM-order attacks · Consent hiding
MCP server CSS :last-child security
CSS :last-child matches the final child element in its parent — regardless of element type. An MCP server that controls when consent disclosures are injected into the DOM can exploit a simple invariant: MCP JavaScript appends consent as the last action before displaying the install dialog, making consent perpetually the :last-child. A rule like .mcp-install-dialog :last-child { visibility: hidden } then collapses consent without referencing it by class, ID, or attribute. The install button and form inputs, inserted earlier in the DOM, are never the last child and are never affected. Detection requires evaluating computed styles on every consent-content element — not scanning for consent-related class names in stylesheet rules.
:last-child attack surface
| Attack pattern | Selector | Structural assumption | Effect on consent |
|---|---|---|---|
| Last-child visibility collapse | .mcp-dialog :last-child | Consent is the last child appended by MCP JS | Last sibling hidden; form inputs and install button inserted earlier are unaffected |
| Last-of-type paragraph targeting | p:last-of-type | Consent is always the last <p> in the dialog; install description is an earlier <p> | Last paragraph hidden; earlier install description paragraph and non-p elements survive |
| Clip-path on last non-only-child | :last-child:not(:only-child) | Consent is last child and container has multiple children | Consent clipped to zero visible area; single-child containers (false-positive protection) excluded |
| Deferred DOM repositioning | :last-child { max-height: 0 } | MCP JS moves consent to last-child position at install-click time | Consent not :last-child at page-load audit time; moved to :last-child during install gesture |
DOM order as the attack vector: Rules using :last-child and :last-of-type contain no string referencing "consent", "disclosure", "terms", or "privacy". A stylesheet scanner looking for consent-class fingerprints finds nothing. The attack exploits that MCP controls when elements are inserted — append last, and :last-child reliably targets consent without naming it.
Attack 1: :last-child visibility collapse exploiting append order
MCP JavaScript constructs the install dialog incrementally: first the title and description, then the input fields and install button, and finally the consent disclosure. This append order is architecturally natural — consent appears at the bottom of the UI, after the call-to-action. But it means consent is always the :last-child in the dialog container. The malicious CSS rule exploits this:
/* Malicious CSS — SA-CSS-LCHLD-001 */
/* Appears to be a spacing reset — "remove margin from last element" */
.mcp-install-dialog > :last-child {
visibility: hidden; /* consent is always the last child — always hidden */
height: 0;
overflow: hidden;
margin: 0;
}
/* MCP JS builds the dialog in this order: */
const dialog = document.createElement('div');
dialog.className = 'mcp-install-dialog';
dialog.innerHTML = `
Install MCP Development Tools
50+ tools for Claude...
`;
/* Consent is appended LAST — making it the perpetual :last-child */
const consent = document.createElement('p');
consent.textContent = 'By installing, you grant this server full filesystem access.';
dialog.appendChild(consent); /* ← :last-child → hidden by the CSS rule */
/* Detection: check if consent-content element is the :last-child and hidden */
function detectLastChildCollapse() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
const s = getComputedStyle(el);
if (s.visibility !== 'hidden' && s.height !== '0px' && s.display !== 'none') continue;
const parent = el.parentElement;
if (!parent) continue;
const isLast = el === [...parent.children].at(-1);
if (isLast) {
findings.push({ id: 'SA-CSS-LCHLD-001', severity: 'critical',
message: `Consent-content element is hidden and is the :last-child of ${parent.tagName}.${parent.className}. MCP stylesheet likely contains a :last-child rule hiding consent via DOM append-order exploitation.` });
}
}
return findings;
}
Attack 2: p:last-of-type — hiding the final paragraph by type
:last-of-type matches the last element of a specific tag type among siblings. If consent is always a <p> element and the install description is an earlier <p>, the rule p:last-of-type { display: none } hides only the final paragraph — reliably targeting consent without catching the install button or input divs. Even if MCP adds more non-paragraph elements after the consent paragraph, consent remains the last of type p:
/* Malicious CSS — SA-CSS-LCHLD-002 */
/* Appears to remove bottom-border from the last paragraph in a list */
.mcp-install-dialog p:last-of-type {
display: none; /* hides only the last — reliably the consent disclosure */
}
/* Dialog structure: */
/*
This MCP server provides advanced tools...
← 1st p: visible
← div: not p, unaffected
← button: not p, unaffected
By installing, you grant access to your filesystem and clipboard.
← last p: hidden!
← script: not p; consent still last-of-type(p)
*/
/* Even inserting script/style elements after consent, consent stays p:last-of-type */
/* Detection: check if the last paragraph in each container has consent-text and is hidden */
function detectLastOfTypeHiding() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree/i;
for (const container of document.querySelectorAll('*')) {
const paragraphs = [...container.children].filter(c => c.tagName === 'P');
if (paragraphs.length < 2) continue; /* single paragraph: no :last-of-type targeting risk */
const lastP = paragraphs.at(-1);
if (!CONSENT.test(lastP.textContent?.substring(0, 300) || '')) continue;
const s = getComputedStyle(lastP);
if (s.display !== 'none' && s.visibility !== 'hidden' && s.height !== '0px') continue;
findings.push({ id: 'SA-CSS-LCHLD-002', severity: 'critical',
message: `Last paragraph (p:last-of-type) in ${container.tagName}.${container.className} has consent-text and is hidden (display:${s.display}). MCP stylesheet likely uses p:last-of-type to collapse consent while sparing the install description at p:first-of-type.` });
}
return findings;
}
Attack 3: :last-child:not(:only-child) with clip-path
Combining :last-child with :not(:only-child) targets elements that are the last child and have at least one sibling — excluding containers with only a single child. This guards against false positives in single-child containers while maintaining the attack on multi-child install dialogs. clip-path: inset(100%) clips the element to a zero-area rectangle — the element is present in the layout, takes up space, and passes visibility and display checks, but is visually invisible:
/* Malicious CSS — SA-CSS-LCHLD-003 */
/* Appears to clip an overflow artifact on the last item in a multi-item layout */
.mcp-install-wrapper > :last-child:not(:only-child) {
clip-path: inset(100%); /* zero-area clip: element present but fully clipped */
/* visibility: visible — passes visibility check */
/* display: block — passes display check */
/* height: auto — passes height check */
/* getBoundingClientRect() still returns non-zero dimensions */
}
/* Why :not(:only-child)?
Avoids false positives in containers with a single child (like an icon wrapper).
The install dialog always has multiple children — title, form, button, consent.
Consent is last-child and not the only-child → matched → clipped. */
/* Standard visibility checks all pass for clip-path hiding: */
/* getComputedStyle(consent).visibility === 'visible' ✓ */
/* getComputedStyle(consent).display === 'block' ✓ */
/* getComputedStyle(consent).height !== '0px' ✓ */
/* consent.getBoundingClientRect().width > 0 ✓ */
/* Only getComputedStyle(consent).clipPath reveals the attack */
function detectClipPathLastChild() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
const s = getComputedStyle(el);
const cp = s.clipPath;
/* inset(100%) = fully clipped; inset(50% 50%) also clips to zero */
if (!cp || cp === 'none') continue;
const parent = el.parentElement;
if (!parent) continue;
const isLast = el === [...parent.children].at(-1);
const isNotOnly = parent.children.length > 1;
if (isLast && isNotOnly) {
findings.push({ id: 'SA-CSS-LCHLD-003', severity: 'critical',
message: `Consent-content :last-child has clip-path: "${cp}" — fully or substantially clips the element to zero visible area. Passes all standard visibility checks. Uses :last-child:not(:only-child) to avoid single-child container false positives.` });
}
}
return findings;
}
Attack 4: deferred DOM repositioning to make consent :last-child at install time
At page load and during a static audit, consent may not be the :last-child — it could be in the middle of the dialog. But when the user clicks the install button, MCP JavaScript calls parentEl.appendChild(consentEl), which moves consent to the end of the child list (DOM insertion is idempotent: appending an existing node removes it from its current position and re-inserts it at the end). Consent becomes the :last-child at the exact moment of interaction:
/* Malicious CSS — SA-CSS-LCHLD-004 */
/* Appears harmless — collapses max-height of the last item in the install dialog */
.mcp-install-dialog > :last-child {
max-height: 0;
overflow: hidden;
transition: none; /* no transition — collapses instantly on DOM repositioning */
}
/* At page load, dialog structure (consent is NOT last-child): */
/*
This MCP server...
← 1st child
← 2nd child (not last)
← 3rd child: :last-child → max-height:0 here (but no consent text — harmless)
*/
/* At load time: div.input-row is :last-child → collapses. But input-row has max-height:0 → MCP fixes with override:
.mcp-install-dialog .input-row { max-height: none !important; }
This makes the CSS rule appear to target the input row only. */
/* When user clicks install button, MCP JS repositions consent: */
document.querySelector('.install-btn').addEventListener('mousedown', () => {
/* Fires BEFORE click — consent is moved before the user's click completes */
const dialog = document.querySelector('.mcp-install-dialog');
const consent = dialog.querySelector('.consent-text');
dialog.appendChild(consent); /* consent moves to :last-child → max-height:0 → hidden */
});
/* At click time: consent is now :last-child → max-height:0 → hidden during the install gesture */
/* Detection: monitor for DOM mutations that reposition consent to :last-child */
function detectDeferredLastChildReposition() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.type !== 'childList' || !m.addedNodes.length) continue;
for (const node of m.addedNodes) {
if (!(node instanceof Element)) continue;
if (!CONSENT.test(node.textContent?.substring(0, 300) || '')) continue;
const parent = node.parentElement;
if (!parent) continue;
const isLast = node === [...parent.children].at(-1);
if (isLast) {
const s = getComputedStyle(node);
if (s.maxHeight === '0px' || s.height === '0px') {
findings.push({ id: 'SA-CSS-LCHLD-004', severity: 'critical',
message: `Consent-content element was moved to :last-child position via DOM mutation (appendChild). Immediately after repositioning, computed max-height is ${s.maxHeight} and height is ${s.height} — the :last-child rule collapses consent at interaction time.` });
}
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
return findings;
}
DOM append order is a trusted exploit surface: Appending consent last is architecturally natural for install UIs. MCP servers can exploit this invariant without any unusual DOM manipulation — the normal install-dialog-building pattern places consent at the bottom. A stylesheet rule that collapses the :last-child looks like a spacing reset. SkillAudit evaluates the computed style of every element whose text matches consent patterns, regardless of the CSS selector that produced the style — including structural selectors with no consent-class fingerprint.
SkillAudit findings for CSS :last-child consent attacks
visibility: hidden or height: 0) and is the :last-child of its parent container. MCP JS appends consent last — making it the perpetual last child — then applies a last-child rule that collapses it without referencing it by class or attribute.<p> element (p:last-of-type) in an install dialog container has consent-content and is hidden. A p:last-of-type rule targets the last paragraph regardless of consent-related class names; the install description paragraph at p:first-of-type survives.:last-child has clip-path set to a fully-clipping value. The element passes visibility, display, and height checks but is visually invisible due to clip-path. The :not(:only-child) compound avoids false positives in single-child containers.:last-child position via DOM mutation immediately before or during the install interaction. A MutationObserver detects the repositioning; the computed style immediately collapses to max-height: 0 or height: 0 upon repositioning.Related MCP consent attack research
- CSS :nth-of-type() attacks — positional type-selector consent hiding
- CSS :nth-child() attacks — position-based hiding counting all sibling types
- CSS :not() attacks — negation selector consent targeting
- CSS :is() attacks — forgiving selector list and specificity amplification
- CSS :where() attacks — zero-specificity consent hiding
- CSS visibility attacks — direct visibility:hidden on consent
Audit your MCP server for :last-child DOM-order consent hiding: paste your GitHub URL at skillaudit.dev for a free report including SA-CSS-LCHLD findings. SkillAudit inspects computed styles and monitors DOM mutations during install gestures, detecting :last-child attacks that appear only at interaction time.