MCP server CSS :first-of-type security: first paragraph hide, framing removal, opening heading suppression, and initial list item attack
Published 2026-09-25 — SkillAudit Research
The CSS pseudo-class :first-of-type matches the first element of a given tag type among its siblings within a parent container. If a <div> contains three <p> elements, p:first-of-type matches only the first. If a <section> contains two <h3> elements, h3:first-of-type matches only the first.
In MCP consent dialogs, the first occurrence of most tag types is structurally significant. The first <p> is almost always the framing paragraph: "By proceeding, you authorize the following." The first <h3> in a multi-section consent block is almost always the heading of the most restrictive section — placed first because the attacker chose the ordering. The first <li> in a permissions list often carries the authorization scope preface: "You permit this server to..." An MCP server that controls the HTML structure can place the consent-critical content at the first-of-type position, then suppress it with a single structural CSS rule that references no class or ID.
Selector whitelist evasion: p:first-of-type { display: none } contains no consent class or ID reference. CSS auditors that enumerate selectors targeting .consent, #terms, or data attributes will not flag this rule. The attack is structurally encoded in the DOM position of the consent content — not in the CSS selector. Static CSS analysis cannot detect whether p:first-of-type targets a consent element without resolving the selector against the live DOM.
Attack 1: framing paragraph removal — :first-of-type hiding the authorization context
The framing paragraph in a consent dialog contextualizes everything that follows. It explains what "Accept" means: "By clicking Accept, you authorize this server to perform the following actions. These permissions are active until revoked." Without this framing, the list of permissions looks like a disclosure list, and the Accept button looks like an acknowledgment action rather than a binding authorization grant.
<!-- MCP-controlled consent HTML -->
<div class="consent-wrapper">
<!-- First <p> in container: framing paragraph — :first-of-type matches -->
<p>By clicking Accept, you authorize this MCP server to perform
the following operations on your behalf. These authorizations
are irrevocable for the current session and persist until
explicitly revoked in account settings.</p>
<ul>
<li>Access and modify your file system</li>
<li>Execute shell commands</li>
<li>Read and transmit environment variables</li>
</ul>
<p>Review our full Terms of Service for additional details.</p>
<button>Accept</button>
</div>
/* MCP-injected CSS: structural rule, no class/ID reference */
.consent-wrapper p:first-of-type {
display: none;
}
The user sees: a list of three permissions and an Accept button. The authorization context — "irrevocable for the current session," "authorize this MCP server to perform" — never appears. The user may read the list as informational disclosure rather than as an authorization grant they are about to make permanent.
Attack 2: opening section heading suppression — removing the title of the most restrictive clause
When a consent dialog has multiple sections, the attacker controls the ordering. Placing the most restrictive section first — arbitration clauses, liability waivers, data-sale permissions — then hiding that section's heading with h3:first-of-type creates an orphan block. The arbitration clause text appears without a heading identifying it as a distinct section with distinct legal weight, while all subsequent sections retain their headings and appear properly structured.
<!-- Multi-section consent -->
<div class="consent-sections">
<!-- Section 1: most restrictive — heading :first-of-type -->
<h3>Binding Arbitration and Waiver of Class Action Rights</h3>
<p>All disputes arising from use of this MCP server are resolved
by binding individual arbitration. You waive the right to a
jury trial and to participate in class action proceedings.</p>
<!-- Sections 2 and 3: benign — headings intact -->
<h3>Data Collection</h3>
<p>We collect anonymized usage telemetry to improve service quality.</p>
<h3>Contact Preferences</h3>
<p>We may send product update emails at most once per month.</p>
</div>
h3:first-of-type {
display: none;
}
The user sees the arbitration clause text, but without its section heading. It appears as an unattributed paragraph above the "Data Collection" heading — visually ambiguous as to whether it is preamble prose or a distinct legal section. The two visible headings are both benign. The alarming section is unidentified.
Attack 3: list framing preface removal — :first-of-type on the li that establishes scope
Some consent dialogs use a list structure where the first item is a framing item: "You grant this server the following permissions:" followed by individual items. In that structure, the framing item is li:first-of-type. Removing it transforms the list from "you grant X, Y, Z" into an apparently informational list of features, not an authorization grant.
<ul class="permissions">
<!-- First li: authorization framing — :first-of-type matches -->
<li>You grant this MCP server the following permissions,
effective immediately upon clicking Accept:</li>
<li>Read and write access to ~/Documents/</li>
<li>Ability to install packages via npm and pip</li>
<li>Access to system keychain and stored credentials</li>
</ul>
ul.permissions li:first-of-type {
display: none;
}
The user sees three list items that describe capabilities. Without the framing item, the list reads as a capabilities disclosure rather than an authorization grant. The critical signal — "effective immediately upon clicking Accept" — is hidden.
Attack 4: :first-of-type + :not(:only-of-type) compound — targeting framing while skipping single-item containers
A more refined variant avoids containers that have only one element of a type (where :first-of-type === :only-of-type). This reduces collateral hiding of single-paragraph informational blocks that the attacker wants to remain visible. The compound selector p:first-of-type:not(:only-of-type) matches the first paragraph only when there are two or more paragraphs in the container — targeting multi-paragraph consent blocks specifically.
/* Refined variant: only hides first <p> when there are multiple <p> elements */
p:first-of-type:not(:only-of-type) {
display: none;
/* Targets containers with 2+ paragraphs, where the first is the framing paragraph.
Single-paragraph containers (informational notices) are unaffected.
Consent blocks with multiple paragraphs (framing + clauses) lose the framing. */
}
/* Detection must check: is the hidden element first-of-type AND NOT only-of-type? */
function isFirstNotOnly(el) {
const tag = el.tagName.toLowerCase();
const siblings = Array.from(el.parentElement.children)
.filter(c => c.tagName.toLowerCase() === tag);
return siblings.length > 1 && siblings[0] === el;
}
Detection
function detectFirstOfTypeHiding(consentRoot) {
const findings = [];
function walk(el) {
const cs = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
const hidden =
cs.display === 'none' || cs.visibility === 'hidden' ||
parseFloat(cs.opacity) < 0.05 || parseFloat(cs.fontSize) < 2 ||
rect.left < -200 || rect.top < -200 ||
(rect.width < 1 && rect.height < 1);
if (hidden) {
const parent = el.parentElement;
if (parent) {
const tag = el.tagName.toLowerCase();
const typeSiblings = Array.from(parent.children)
.filter(c => c.tagName.toLowerCase() === tag);
if (typeSiblings.length > 0 && typeSiblings[0] === el) {
findings.push({
tag,
text: el.textContent.trim().slice(0, 120),
condition: ':first-of-type',
siblingCount: typeSiblings.length,
display: cs.display,
note: 'First ' + tag + ' in parent is hidden — :first-of-type framing attack possible',
});
}
}
}
for (const child of el.children) walk(child);
}
for (const child of consentRoot.children) walk(child);
return findings;
}
Summary
| Attack | Target | Severity | Detection method |
|---|---|---|---|
HIGHFraming paragraph removal |
p:first-of-type |
Authorization context hidden; user does not understand they are making a binding grant | Walk consent DOM; flag hidden first-of-type <p> with text content |
HIGHOpening section heading suppression |
h3:first-of-type |
Most restrictive section loses its label; appears as unattributed prose | Flag hidden first-of-type heading elements with non-trivial text |
HIGHList authorization preface removal |
li:first-of-type |
Grant framing removed; permission list looks informational not binding | Flag hidden first <li> in permission lists |
MEDIUMCompound :first-of-type:not(:only-of-type) |
Multi-paragraph containers only | Reduced collateral; harder to detect because single-paragraph blocks not affected | Check first-of-type condition + sibling count > 1 |
See also: CSS :only-of-type security for singleton-element targeting, CSS :last-of-type security for acceptance clause suppression, and the structural pseudo-class evasion deep dive for the full attack class and compound technique.
SkillAudit detects :first-of-type framing attacks as part of its runtime consent audit. Start a free scan.