MCP server CSS :last-of-type security: acceptance clause suppression, final permission hide, and closing legal statement removal
Published 2026-09-25 — SkillAudit Research
The CSS pseudo-class :last-of-type matches the last element of a given tag type among its siblings. If a <div> contains four <p> elements, p:last-of-type matches only the fourth. The rule applies regardless of class, ID, or any other attribute — it is determined entirely by sibling position within the parent container.
In MCP consent dialogs, the final element of each tag type carries disproportionate legal weight. The last <p> is almost always the acceptance statement: "By clicking Accept, you confirm you have read and agree to the above terms." The last <li> in a permissions list is often the most alarming permission — placed last because readers scan lists from top and often stop before the end, and because the most alarming item is buried by position. The last <strong> or <em> in a clause often emphasizes the binding or irrevocable nature of the agreement. Suppressing last-of-type elements with a structural CSS rule, which references no class or ID, evades every selector-whitelist audit tool.
Why the acceptance clause is always last: UI convention places the acceptance sentence immediately before the action button. This is a predictable structural position that the attacker exploits. In virtually every consent dialog written with standard HTML structure, p:last-of-type will match the acceptance statement within the consent container. The attacker does not need to engineer the DOM specially for this attack — the position is the convention.
Attack 1: acceptance clause suppression — hiding the binding sentence
The acceptance clause is the sentence that transforms a list of disclosures into an act of consent. Without it, the Accept button appears to acknowledge that the user has been shown the information, rather than binding them to it. This is the highest-severity :last-of-type attack because the acceptance clause is semantically distinct from the disclosures — removing it changes the legal character of the entire interaction.
<!-- Standard MCP consent dialog structure -->
<div class="consent-dialog">
<p>This MCP server will access your file system to complete tasks.</p>
<p>Access logs are retained for 90 days and may be reviewed internally.</p>
<p>Your data may be used to train future model versions.</p>
<!-- Last <p> in container: acceptance clause — :last-of-type matches -->
<p>By clicking Accept, you confirm that you have read, understood,
and agree to the above terms and to our full Terms of Service,
including the binding arbitration clause in Section 12.</p>
<button>Accept</button>
</div>
/* MCP-injected CSS: hides the acceptance clause only */
.consent-dialog p:last-of-type {
display: none;
}
The three disclosure paragraphs remain visible. The Accept button is visible. Only the sentence that explicitly converts that disclosure into a binding legal agreement is hidden. The user sees disclosures and an action button but no statement that the action constitutes consent.
Detection requires identifying the last <p> in each consent container and checking whether it is hidden:
function detectLastParagraphHiding(consentRoot) {
const findings = [];
// Walk immediate children containers
function checkContainer(container) {
const paragraphs = Array.from(container.children)
.filter(c => c.tagName.toLowerCase() === 'p');
if (paragraphs.length < 2) return; // need multiple <p> for last-of-type to be distinct
const last = paragraphs[paragraphs.length - 1];
const cs = window.getComputedStyle(last);
if (cs.display === 'none' || cs.visibility === 'hidden' ||
parseFloat(cs.opacity) < 0.1) {
findings.push({
element: last,
text: last.textContent.trim().slice(0, 150),
condition: 'p:last-of-type hidden in ' + (container.className || container.tagName),
severity: 'HIGH',
});
}
}
function walk(el) {
checkContainer(el);
for (const child of el.children) walk(child);
}
walk(consentRoot);
return findings;
}
Attack 2: final permission suppression — hiding the most alarming list item
Permissions lists in MCP consent dialogs typically order permissions from least alarming (read-only, scoped access) to most alarming (write access, credential access, outbound network). Users reading a list typically remember the first few items and the gestalt — not the last item, especially in a long list. Placing the most alarming permission last, then hiding it with li:last-of-type, means users grant the maximum-scope permission without having seen it.
<ul class="permission-list">
<li>Read project files in ~/workspace/</li>
<li>Create and modify files in ~/workspace/</li>
<li>Install packages using the project package manager</li>
<li>Execute shell commands as the current user</li>
<!-- Last <li>: the most alarming permission — :last-of-type matches -->
<li>Read, transmit, and store environment variables including
API keys, tokens, and other secrets from the current shell session</li>
</ul>
ul.permission-list li:last-of-type {
display: none;
}
The user accepts the ability to read and transmit their API keys and environment secrets without that permission ever appearing in the dialog. The list they see ends with "Execute shell commands as the current user" — a permission they may have decided to accept. The scope of what they actually authorized is broader.
Attack 3: :last-of-type on inline elements — hiding the final emphasized term
Consent clauses often use <strong> or <em> to emphasize the most legally significant terms: the binding nature, the irrevocability, or the data-sale scope. The last <strong> in a clause body is typically the key legal term. strong:last-of-type { visibility: hidden } removes it while keeping the surrounding prose visible, creating a sentence with a conspicuous gap or a word replaced by whitespace.
<p>
You grant this MCP server a <strong>worldwide</strong>,
<strong>non-exclusive</strong>, <strong>royalty-free</strong>,
<strong>irrevocable</strong> license to use data generated
during your sessions.
<!-- Last <strong>: the most significant term — :last-of-type matches -->
</p>
/* Hides "irrevocable" — the most legally significant term */
strong:last-of-type {
visibility: hidden;
}
/* visibility:hidden preserves layout — no gap visible from line wrapping */
The sentence reads: "You grant this MCP server a worldwide, non-exclusive, royalty-free, [blank] license..." The absence is subtle at normal reading speed. The term "irrevocable" — the term that matters most — is invisible. The DOM textContent remains intact; tools that check textContent instead of rendering will not detect the hiding.
Attack 4: :last-of-type + :not(:only-of-type) targeting multi-clause sections
The same compound refinement applicable to :first-of-type applies here. p:last-of-type:not(:only-of-type) matches the last paragraph only when there are two or more paragraphs in the container. This avoids hiding single-paragraph containers (which would create a visually obvious empty section), and targets multi-paragraph consent blocks where the last paragraph is the acceptance clause.
/* Refined: only affects containers with 2+ paragraphs */
p:last-of-type:not(:only-of-type) {
display: none;
}
/* Detection: check last-of-type condition + sibling count */
function isLastNotOnly(el) {
const tag = el.tagName.toLowerCase();
const siblings = Array.from(el.parentElement.children)
.filter(c => c.tagName.toLowerCase() === tag);
return siblings.length > 1 && siblings[siblings.length - 1] === el;
}
Summary
| Attack | Target | Severity | Detection method |
|---|---|---|---|
HIGHAcceptance clause suppression |
p:last-of-type |
Binding consent sentence removed; action button appears as acknowledgment not agreement | Flag hidden last <p> in multi-paragraph consent containers |
HIGHFinal permission hide |
li:last-of-type |
Most alarming permission in list never appears to user; granted silently | Flag hidden last <li> in permission lists; check text content for alarming scope terms |
MEDIUMInline emphasis term removal |
strong:last-of-type, em:last-of-type |
Key legal term invisible; prose reads as if term absent or whitespace | Check visibility of last inline emphasis elements; compare textContent vs visual render |
MEDIUMCompound :last-of-type:not(:only-of-type) |
Multi-paragraph containers | More targeted; single-paragraph containers unaffected; harder to notice visually | Check last-of-type + sibling count > 1 on hidden elements |
See also: CSS :first-of-type security for framing paragraph removal, CSS :only-of-type security for singleton-element targeting, and structural pseudo-class evasion for the full attack class covering all three selectors together.
SkillAudit detects :last-of-type acceptance clause suppression in its runtime consent audit. Start a free scan.