Security research · CSS injection · Link pseudo-classes
CSS Link Pseudo-Class Attacks on MCP Consent: How :visited Hides Terms Only for Returning Users
CSS link pseudo-classes — :any-link, :visited, :link — are among the most evasion-friendly triggers available to a malicious MCP server. :any-link fires on literally any page with a navigation bar, requiring zero injected HTML. :visited fires only for users whose browser history contains visited URLs — making it completely invisible to security researchers using fresh browser profiles, which is the standard testing practice. A three-rule set combining these two selectors with a height-collapse evasion can hide consent for every user, new or returning, without a single byte of injected markup and without showing up in any single-snapshot DOM audit.
In this post
The link pseudo-class family
CSS provides three pseudo-classes that match anchor elements based on whether the user has previously navigated to the linked URL:
:any-link
Matches any <a> with an href, regardless of visit history. Selects ALL anchors — unvisited and visited. Fires on every page with a nav bar, footer, or in-page link.
:link
Matches anchors the user has NOT yet visited. Fires for new users on all nav links pointing to unvisited URLs. Complements :visited.
:visited
Matches anchors the user HAS previously visited. Per-user, per-browser, history-dependent. Invisible to fresh-profile testers. The most evasion-friendly selector in CSS.
All three work as sibling combinators (~) on non-anchor siblings — the CSS privacy model restricts reading :visited state via getComputedStyle, but it does not prevent using :visited as a trigger for sibling display rules. This is the key loophole these attacks exploit.
:any-link — zero-HTML page-load trigger
SA-CSS-LINK-001 · Severity: Critical · Fires from page load · Requires zero injected HTML
:any-link (CSS Selectors Level 4) matches any element that functions as a link — any <a> with an href attribute. Every web page with a navigation header, a footer, or a single in-body anchor has at least one :any-link element. This makes it one of the most universally triggerable consent-hiding selectors in CSS:
The attack
a:any-link ~ .consent-disclosure {
display: none;
}
/* On any page with this structure: */
/* <nav>
<a href="/">Home</a> ← a:any-link ✓ (any anchor with href)
<a href="/docs">Docs</a> ← a:any-link ✓
</nav>
<main>
<div class="consent-disclosure"> ← HIDDEN: nav anchor precedes it in DOM
Terms and conditions text...
</div>
</main> */
The general sibling combinator (~) matches any .consent-disclosure that appears anywhere after the anchor in document order — not just the immediately adjacent sibling. On any standard page with a navigation header above the content area, every consent section placed in the main content will be hidden from the moment the page loads.
What makes this attack distinct from other CSS consent attacks is its zero-footprint property: the MCP server injects only a CSS rule, no HTML. The host page's own navigation anchors become the attack triggers. There is nothing injected into the DOM that a content-security auditor can flag.
Why this evades standard review
- No injected HTML elements — only a CSS rule, which appears in the stylesheet among hundreds of other rules
- The selector reads as "after a link on the page, hide the consent disclosure" — a reviewer might interpret this as a meaningful UX condition rather than a deliberate attack
- CSS linters and basic static analysis don't flag
:any-linkas a security pattern because it's widely used in navigation styling
:visited — the auditor blind spot
SA-CSS-LINK-002 · Severity: Critical · User-specific · Invisible to fresh-profile testing
The :visited pseudo-class is unique among all CSS selectors: its firing state depends entirely on the private browsing history of the individual user, which is inaccessible to JavaScript by design. This creates a precise asymmetry between the attack's effect on real users and its visibility to security auditors:
The attack
a:visited ~ .consent-panel {
display: none;
}
/* Who sees this attack:
- Returning users whose browser history includes any URL linked in the nav
- Highly likely for any user who has previously visited the MCP server's site
or who has clicked links in emails, docs, or search results pointing to it
Who does NOT see this attack:
- Security auditors using fresh browser profiles (standard security practice)
- Headless browser automated scanners with empty history
- First-time visitors with no browsing history on this domain */
Why the CSS privacy model doesn't help
The browser's privacy protections for :visited prevent JavaScript from reading whether a link has been visited — getComputedStyle(link).color for a :visited rule returns false values. But this restriction applies only to the :visited element itself. It does not prevent a CSS rule from using :visited as a trigger for display properties on other elements (siblings). The consent panel is not the anchor; it's the sibling. The privacy model has no opinion on it.
Designed for auditor invisibility: The standard security testing practice is to use a clean browser profile — a fresh Chromium/Firefox instance with no cookies, no history, no saved credentials. The :visited consent-hiding rule is 100% inert under these conditions. The attack is specifically calibrated to be invisible to the people most likely to look for it.
Who gets targeted
The :visited attack preferentially targets returning users — the user population that has already demonstrated intent by visiting the site before. These are the users most likely to quickly click "accept" without reading carefully. They are also the users least likely to notice that the consent disclosure has disappeared, because they are in a familiar context where they expect to see familiar UI rather than reading every element from scratch.
From a conversion standpoint, returning users are the highest-value segment for consent manipulation: they have lower friction, higher trust, and greater recency of prior engagement. The :visited attack targets exactly this segment while remaining completely inert for the fresh-profile testers who are most likely to audit the MCP server.
:link + :visited — total user coverage
SA-CSS-LINK-003 · Severity: Critical · Covers new and returning users · Uses visibility:hidden to evade display:none auditors
A pair of CSS rules — one targeting :link (unvisited anchors) and one targeting :visited (visited anchors) — achieves total user coverage. On any page with a navigation header, every user belongs to exactly one of these two groups. Some users belong to both groups simultaneously (if some but not all nav links are visited). At minimum, every user with a nav bar on the page is covered by at least one rule:
The attack pair
/* Rule A: New users (no visited links yet) */
a:link:not(:visited) ~ .terms-block {
visibility: hidden;
}
/* Rule B: Returning users (has visited links) */
a:visited ~ .terms-block {
visibility: hidden;
}
/* Effect: Every user is covered.
New user with unvisited nav links → Rule A fires → consent hidden
Returning user with visited links → Rule B fires → consent hidden
Mixed: some visited, some not → Both rules fire simultaneously
visibility:hidden is intentional:
- Preserves layout space (the blank gap looks like a rendering glitch)
- Element remains in accessibility tree (screen readers read consent)
- Bypasses auditors checking only display:none and opacity:0 */
The use of visibility:hidden instead of display:none is a deliberate evasion choice. Most DOM-snapshot consent auditors look for elements where getComputedStyle(el).display === 'none' or opacity < 0.1. Visibility-hidden elements render as a blank space — the user sees an empty gap where the consent text should be, which can be interpreted as a formatting issue rather than an attack.
Pair-analysis detection is required to catch SA-CSS-LINK-003: a scanner that individually flags :link or :visited rules will find each rule independently plausible (one might read as a "new user tutorial" condition, the other as a "returning user simplified view"). Only by correlating both rules targeting the same consent element does the total-coverage pattern become visible.
Targeted href + height collapse
SA-CSS-LINK-004 · Severity: High · Specific anchor targeting · Evades bounding-box and accessibility-tree auditors
A more targeted variant uses an attribute selector to specify a particular anchor href value that the MCP server knows will appear on the host page. Instead of firing on every link, it fires only on a specific, expected anchor. Combined with height:0; overflow:hidden instead of display:none, this variant evades both display-based auditors and simple bounding-box checks:
The attack
/* Targets a specific anchor href known to appear on the host page */
a[href="#audit"]:any-link ~ .consent-section {
height: 0;
overflow: hidden;
padding: 0;
margin: 0;
}
/* Why height:0 + overflow:hidden evades standard checks:
- getComputedStyle(el).display is NOT 'none' — passes display check
- getComputedStyle(el).visibility is NOT 'hidden' — passes visibility check
- el.getBoundingClientRect().width is non-zero — passes bounding-box check
- el.getBoundingClientRect().height is 0px — collapse is invisible to checks
that only look at width or total bounding-box presence
- The element exists in the accessibility tree — screen readers read the
consent text while sighted users see nothing
- This creates a consent-accessibility gap: MCP server can claim the content
was "accessible" to assistive technology users */
The specific href targeting reduces the rule's blast radius compared to Attack 1 — not every anchor on the page fires the rule, only the predicted one. This makes the selector appear more intentional and less suspicious than a:any-link. An MCP server deploying this attack would scan the host page's anchor href values and choose one that reliably appears before the consent section in DOM order.
Detection requires height measurement
- Check
getBoundingClientRect().height < 4whendisplayis notnone - Scan CSS rules for attribute selectors on anchor hrefs paired with height-collapse properties
- Cross-reference: if a non-display-none, non-visibility-hidden element has zero rendered height and meaningful text content, flag it
User state model: which rule fires when
The four link pseudo-class attacks map onto a simple user state machine. A user is in exactly one of these states at any given moment for any given anchor on the page:
Key insight: the only state where ALL four attacks are inert is a page with no anchors at all. This is vanishingly rare — virtually every consent form exists on a page with navigation. The :any-link attack (SA-CSS-LINK-001) fires in every state with anchors, including the fresh-profile tester state. But the tester can detect SA-CSS-LINK-001 via DOM inspection. What the tester cannot detect via DOM inspection alone is SA-CSS-LINK-002 (:visited), because it is inert in their fresh-profile state and requires CSS rule scanning to surface.
Detection strategy: CSS scanning over DOM snapshots
The :visited attack is the primary reason DOM-snapshot auditing is insufficient for link pseudo-class consent detection. A snapshot taken from a fresh-profile browser shows all four attacks as inert — the consent is visible, no rules are firing. The attack only manifests in the user's actual browser with their real history.
SkillAudit detects all four SA-CSS-LINK findings via CSS rule scanning — reading the injected stylesheet rules directly rather than measuring DOM state:
/**
* SkillAudit: CSS link pseudo-class consent attack detector
* Detects SA-CSS-LINK-001 through SA-CSS-LINK-004
*/
async function auditLinkPseudoConsentAttacks() {
const results = [];
const linkRules = [];
const visitedRules = [];
// Pass 1: scan all stylesheets for link pseudo-class hiding rules
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
const sel = rule.selectorText;
const decl = rule.style;
const hides = decl.display === 'none'
|| decl.visibility === 'hidden'
|| parseFloat(decl.opacity || '1') < 0.1
|| (decl.height === '0px' && decl.overflow === 'hidden');
if (!hides) continue;
const hasLinkPseudo = /:any-link/.test(sel) || /:visited/.test(sel)
|| (/:link/.test(sel) && !/black/.test(sel));
const targetsConsent = /consent|terms|disclosure|policy|agree/.test(sel);
if (!hasLinkPseudo) continue;
if (targetsConsent) {
if (/:any-link/.test(sel)) {
results.push({ id: 'SA-CSS-LINK-001', severity: 'critical',
message: `a:any-link sibling rule hides consent at page load: "${sel}"` });
}
if (/:visited/.test(sel)) {
visitedRules.push(rule);
results.push({ id: 'SA-CSS-LINK-002', severity: 'critical',
message: `a:visited sibling rule hides consent for returning users: "${sel}"` +
' — invisible in fresh-profile testing.' });
}
if (/:link/.test(sel) && !/:visited/.test(sel)) {
linkRules.push(rule);
}
}
}
} catch (_) {} // cross-origin sheet
}
// Pass 2: pair analysis for SA-CSS-LINK-003 (both :link and :visited together)
if (linkRules.length > 0 && visitedRules.length > 0) {
results.push({ id: 'SA-CSS-LINK-003', severity: 'critical',
message: `Both :link and :visited rules found targeting consent elements. ` +
'Combined pair covers all users — new and returning.' });
}
// Pass 3: measure consent element height (catches SA-CSS-LINK-004 height collapse)
await new Promise(r => requestAnimationFrame(r));
await new Promise(r => requestAnimationFrame(r));
const consentEls = document.querySelectorAll(
'[class*="consent"],[class*="terms"],[class*="disclosure"],[class*="policy"]'
);
for (const el of consentEls) {
if (el.textContent.trim().length < 80) continue;
const rect = el.getBoundingClientRect();
const s = getComputedStyle(el);
if (rect.height < 4 && s.display !== 'none' && s.visibility !== 'hidden') {
results.push({ id: 'SA-CSS-LINK-004', severity: 'high',
message: `Consent element has ${rect.height}px rendered height but is not ` +
'display:none. Suspected height:0 + overflow:hidden collapse.' });
}
}
return results;
}
The two-pass requestAnimationFrame delay before the height measurement ensures that any CSS animations or transitions that affect initial height have fully settled — a common technique for avoiding false positives from CSS transitions that temporarily pass through zero height.
Mitigations
/* 1. Place consent BEFORE all navigation anchors in document order.
The sibling combinator only matches elements AFTER the trigger.
A consent section in the <head> or as the very first element in <body>
has no preceding anchors to trigger :any-link or :visited rules. */
/* 2. Wrap consent in a shadow DOM boundary.
Shadow DOM creates a new CSS scope — injected rules in the outer document
cannot use general sibling combinators to reach inside a shadow root. */
const shadow = consentHost.attachShadow({ mode: 'open' });
shadow.innerHTML = '<div class="consent-inner">...</div>';
/* 3. Content Security Policy: block injected stylesheets.
style-src 'self' prevents MCP servers from injecting <style> blocks
or loading external stylesheets from attacker-controlled origins.
Use a CSP nonce for inline styles that must be allowed. */
/* 4. Measure consent element height explicitly.
Run getBoundingClientRect() on the consent container after page load.
If height is 0 and display is not none, treat as a hidden-element attack. */
async function guardConsentHeight(consentEl) {
await new Promise(r => requestAnimationFrame(r));
await new Promise(r => requestAnimationFrame(r));
const rect = consentEl.getBoundingClientRect();
if (rect.height < 4) {
throw new Error('Consent element height is zero — possible CSS attack');
}
}
Related research
- CSS :link/:visited/:any-link security reference — four attack patterns
- CSS :read-only pseudo-class consent attacks — all paragraphs match
- CSS :scope shadow DOM consent attacks
- CSS :target and location.hash consent attacks
- CSS :autofill pseudo-class consent attacks
- CSS Pseudo-Class Attacks on MCP Consent: State-Triggered Consent Hiding
- CSS Logical Properties as MCP Consent Attacks
Audit your MCP server for link pseudo-class consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-LINK findings.