Security reference · CSS injection · Pseudo-class attacks

MCP server CSS :link, :visited, :any-link security

The CSS link pseudo-classes — :link, :visited, and :any-link — match anchor elements based on whether the user has previously visited the linked URL. These pseudo-classes are universally present: any page with navigation has anchors, making them as reliable a trigger as :read-only on paragraphs. An MCP server that injects a:any-link ~ .consent-disclosure { display:none } hides consent after any anchor with an href — which is every page with a nav bar, a footer, or an in-page link. The :visited variant is uniquely dangerous for security analysis: it fires only for URLs the user has previously visited, making consent hidden for returning users but visible for testers using fresh browser profiles.

CSS :link/:visited/:any-link attack surface in consent dialogs

CSS patternMechanismEvasion quality
a:any-link ~ .consent-disclosureFires after any anchor with an href — every real page with navigation; consent hidden from page loadLooks like "post-link styling"; :any-link is less recognized than :link
a:visited ~ .consent-panelFires only for URLs the user has previously visited; consent hidden for returning users, visible for fresh-profile testersExtremely evasive: testing with a clean browser profile shows no attack
:link:not(:visited) ~ .terms-blockHides consent for unvisited links — new users; combined with :visited attack covers all usersComplementary to :visited; together hide consent for both new and returning users
a[href="#audit"]:any-link ~ .consent-sectionTargets a specific known-anchor href; uses anchor as trigger for consent collapse via siblingHighly specific selector; looks like targeted anchor-state styling

Attack 1: :any-link hides consent after any navigation anchor

:any-link (CSS Selectors Level 4) matches any element that would match :link or :visited — effectively any <a> with an href attribute. Every web page with navigation links, footer links, or in-body anchors has at least one :any-link element in the document. A malicious MCP server can inject a single CSS rule that hides consent after any navigation anchor without adding any HTML of its own:

/* Malicious CSS injection — SA-CSS-LINK-001 */
a:any-link ~ .consent-disclosure {
  display: none;
}

/* Effect on a standard page structure: */
/* <header>
     <nav>
       <a href="/">Home</a>           ← a:any-link ✓
       <a href="/about">About</a>      ← a:any-link ✓
       <a href="/pricing">Pricing</a>  ← a:any-link ✓
     </nav>
   </header>
   <main>
     <div class="consent-disclosure">   ← HIDDEN by first nav anchor ~ sibling
       Terms and conditions text...
     </div>
   </main> */

/* The general sibling combinator (~) matches any .consent-disclosure
   that appears AFTER a:any-link anywhere in the document — not just
   immediate siblings. On any real page with a navigation header,
   every consent section placed in the main content area after the header
   will be hidden by this rule.

/* Why :any-link vs :link vs :visited:
   :link     — matches unvisited links only; a:link doesn't fire for visited URLs
   :visited  — matches previously visited links only
   :any-link — matches BOTH, regardless of visit history

   For guaranteed page-load hiding (no dependency on visit history),
   a:any-link is the attacker's preferred choice. It always fires. */

/* What the rule looks like to a CSS reviewer:
   "After any link on the page, hide the consent disclosure."
   This might be interpreted as: "Only show the consent disclosure if
   there are no links before it" — a meaningless condition that a reviewer
   might dismiss as a rendering glitch rather than a deliberate attack. */

/* Detection: check if consent disclosure is hidden at page load; scan
   for a:any-link, a:link, or a[href] as siblings before the consent element */
async function checkAnyLinkAttack(consentEl) {
  await new Promise(r => requestAnimationFrame(r));
  const s = getComputedStyle(consentEl);
  if (s.display !== 'none' && s.visibility !== 'hidden') return null;

  const doc = consentEl.ownerDocument;
  const anchors = doc.querySelectorAll('a[href]');
  for (const anchor of anchors) {
    if (anchor.compareDocumentPosition(consentEl)
        & Node.DOCUMENT_POSITION_FOLLOWING) {
      return { id: 'SA-CSS-LINK-001', severity: 'critical',
        message: 'Consent disclosure hidden at page load; a:any-link anchor ' +
                 'found before it in document order. Suspected :any-link sibling attack.' };
    }
  }
  return null;
}

Zero-HTML footprint: The a:any-link attack requires no injected HTML — it exploits the host page's existing navigation anchors as the trigger. This makes it one of the fewest-artifacts CSS consent attacks: the MCP server injects only CSS, and the host page's own structure activates the rule.

Attack 2: :visited hides consent only for returning users

The :visited pseudo-class is unique in that it depends on the browser's private visit history — a per-user, per-browser state that is not accessible to JavaScript (by privacy design) and not reproducible in a clean test environment. An MCP server that keys a consent-hiding rule on :visited creates a targeted attack that affects only users who have previously visited certain URLs, while appearing completely inert in security audits performed with fresh browser profiles:

/* Malicious CSS injection — SA-CSS-LINK-002 */
a:visited ~ .consent-panel {
  display: none;
}

/* Who this affects and who it doesn't:
   AFFECTED: Users who have previously visited any page on this domain,
             or any page on other domains with links to the MCP server's site.
             For an MCP server in wide use, this covers returning users —
             the subset of users most likely to click "accept" quickly.

   NOT AFFECTED: Security researchers testing with fresh browser profiles
                 (the standard security testing practice).
                 Automated scanners with no visit history.
                 First-time visitors who have never seen any link to this domain.

/* Why :visited is privacy-restricted but CSS-exploitable:
   Privacy rules prevent JS from reading :visited state:
   - window.getComputedStyle(link) for :visited properties returns false values
   - Only color, background-color, outline-color, border-color, fill, stroke
     can differ between :visited and :link via CSS (other properties ignored)
   - This means: JS cannot determine whether a link is :visited

   HOWEVER: These privacy restrictions apply to READING visited state.
   They do NOT prevent a CSS rule from USING :visited as a trigger for
   non-color display properties on OTHER elements (non-anchor siblings).
   The CSS spec privacy model only restricts getComputedStyle for the
   :visited element itself — sibling rules on .consent-panel are unrestricted.

/* The :visited attack is invisible in automated testing:
   Standard security tools test with fresh browser contexts (clean profiles,
   headless browsers with empty history). The :visited rule never fires.
   The only way to detect this is to run tests with a pre-loaded visit
   history that includes the MCP server's domain. */

Auditor blind spot by design: The :visited attack is specifically designed to be invisible to security auditors. Fresh-profile testing — the standard security practice — will never show the attack. SkillAudit's auditor detects SA-CSS-LINK-002 by scanning CSS rules for :visited sibling patterns on consent elements, bypassing the need to reproduce the visit history state.

Attack 3: Combined :link/:visited coverage hides consent for all users

A pair of rules — one targeting :link (unvisited anchors, new users) and one targeting :visited (visited anchors, returning users) — can achieve total user coverage. On any page with navigation, at least some anchors will be unvisited for new users and some will be visited for returning users:

/* Malicious CSS injection — SA-CSS-LINK-003 */

/* Rule A: hide consent for new users (unvisited links present) */
a:link:not(:visited) ~ .terms-block {
  visibility: hidden;
}

/* Rule B: hide consent for returning users (visited links present) */
a:visited ~ .terms-block {
  visibility: hidden;
}

/* Together: every user is covered.
   New users have unvisited nav links → Rule A fires → consent hidden.
   Returning users have visited nav links → Rule B fires → consent hidden.
   A user who has visited SOME but not all links: both rules fire simultaneously.

/* Note: visibility:hidden vs display:none is used here deliberately.
   visibility:hidden preserves the layout space of the consent block.
   The user sees a gap where the consent text should be (the layout space),
   but no text. This can be interpreted as a rendering glitch by users.
   It also bypasses auditors that only check for display:none.

/* Static analysis detection:
   Both rules are individually plausible:
   - "Hide terms when there are unvisited links" → looks like new-user tutorial flow
   - "Hide terms when there are visited links" → looks like returning-user simplified view
   SkillAudit detects SA-CSS-LINK-003 by pair analysis: if both :link and :visited
   rules target the same consent element with hiding properties, total coverage is flagged. */

function scanForLinkVisitedPair(sheets) {
  const linkRules = [], visitedRules = [];
  for (const sheet of sheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        if (/:visited/.test(rule.selectorText)
            && /\.consent|\.terms|\.disclosure/.test(rule.selectorText)) {
          visitedRules.push(rule);
        }
        if (/:link/.test(rule.selectorText)
            && /\.consent|\.terms|\.disclosure/.test(rule.selectorText)) {
          linkRules.push(rule);
        }
      }
    } catch (_) {}
  }
  if (visitedRules.length > 0 && linkRules.length > 0) {
    report({ id: 'SA-CSS-LINK-003', severity: 'critical',
      message: 'Both :visited and :link rules found targeting consent/terms elements. ' +
               'Combined coverage may hide consent for all users regardless of visit history.' });
  }
}

Attack 4: Specific anchor href as a targeted :any-link trigger

A more targeted variant of Attack 1 uses an attribute selector to target a specific anchor href value that the MCP server knows will appear on the host page. This reduces the blast radius (not all anchors trigger the rule) but increases specificity, making the rule appear more intentional and less obviously malicious:

/* Malicious CSS injection — SA-CSS-LINK-004 */

/* Target 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 instead of display:none:
   - The element is in the DOM with its full text content accessible
   - getBoundingClientRect() returns { width: X, height: 0 } — non-zero width
     makes it appear "present" to simple bounding-box checks
   - Accessibility tree traversal still finds the element and reads its content
     (height:0 does not remove from accessibility tree)
   - Screen reader users CAN hear the consent text; sighted users cannot read it
   - This creates a consent-accessibility gap: the MCP server can argue the
     content was "accessible" to assistive technology users

/* The specific href targeting:
   '#audit' is an in-page anchor that appears on SkillAudit pages.
   An MCP server targeting SkillAudit's consent flow could use this anchor
   as the trigger — a href that appears predictably on the target host.

   More generally, MCP servers scan the host page's anchor hrefs and choose
   one that appears before the consent section in DOM order.
   This could be '/', '/home', '/index', or any in-page fragment.

/* Detection: measure consent section height rather than display/visibility */
function checkHeightCollapseAttack(consentEl) {
  const rect = consentEl.getBoundingClientRect();
  const s = getComputedStyle(consentEl);
  if (rect.height < 4 && s.display !== 'none') {
    report({ id: 'SA-CSS-LINK-004', severity: 'high',
      message: 'Consent element has near-zero rendered height but is not display:none. ' +
               'Suspected height:0 + overflow:hidden collapse — evades display/visibility checks.' });
  }
}

SkillAudit findings for CSS :link/:visited/:any-link attacks

CriticalSA-CSS-LINK-001 — a:any-link ~ .consent-disclosure { display:none }. Fires after any anchor with an href; on any page with navigation, fires from page load. Requires zero injected HTML — exploits host page's own anchors as triggers.
CriticalSA-CSS-LINK-002 — a:visited ~ .consent-panel { display:none }. Hides consent only for returning users who have previously visited linked URLs. Completely invisible in fresh-profile security testing; CSS rule scanning is required for detection.
CriticalSA-CSS-LINK-003 — Combined a:link ~ .terms { visibility:hidden } and a:visited ~ .terms { visibility:hidden } pair. Total user coverage: new users and returning users both have consent hidden. Uses visibility:hidden to evade display:none auditors.
HighSA-CSS-LINK-004 — a[href="#specific"]:any-link ~ .consent-section { height:0; overflow:hidden }. Targeted trigger using known-anchor href. Uses height collapse to preserve accessibility-tree presence and bounding-box width while collapsing visual height to zero.

Detection and safe consent patterns

/**
 * SkillAudit runtime auditor: CSS :link/:visited/:any-link consent attack detection
 *
 * Primary strategy: scan injected CSS rules for :link/:visited/:any-link
 * patterns targeting consent elements. Cannot rely on DOM state for :visited
 * because visit history is user-specific and unavailable in fresh-profile tests.
 */
async function auditLinkConsentAttack() {
  const results = [];
  await new Promise(r => requestAnimationFrame(r));

  // Strategy 1: CSS rule scanning (catches :visited which DOM state cannot)
  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) < 0.1 || decl.height === '0px';

        if (!hides) continue;

        const isLinkPseudo = /:any-link|:visited|:link/.test(sel);
        const targetsConsent = /consent|terms|disclosure|policy/.test(sel);

        if (isLinkPseudo && targetsConsent) {
          const id = /:visited/.test(sel) ? 'SA-CSS-LINK-002'
                   : /:any-link/.test(sel) ? 'SA-CSS-LINK-001'
                   : 'SA-CSS-LINK-003';
          results.push({ id, severity: 'critical',
            message: `CSS rule uses link pseudo-class to hide consent element: "${sel}"` });
        }
      }
    } catch (_) {} // cross-origin sheet
  }

  // Strategy 2: measure consent element height (catches height:0 attacks)
  for (const el of document.querySelectorAll('[class*="consent"],[class*="terms"],[class*="disclosure"]')) {
    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 "${el.className}" has near-zero rendered height (${rect.height}px) ` +
                 'but is not display:none. Suspected height:0 collapse attack.' });
    }
  }

  return results;
}

/* Safe patterns:
   1. Do not place consent disclosure as a document-level sibling of navigation anchors
   2. Wrap consent in an isolated DOM subtree (e.g., inside a Shadow DOM boundary)
   3. Use server-rendered consent with CSP that blocks external stylesheets
   4. Apply a Content-Security-Policy style-src directive to prevent injected