Security research · CSS attacks · Pseudo-class state

CSS Pseudo-Class Attacks on MCP Consent: State-Triggered Consent Hiding

Published July 30, 2026 By SkillAudit ~1,800 words · 9 min read

Most CSS consent-hiding attacks fail a simple test: load the page, query getComputedStyle on the consent element, and check whether it is visible. But a class of attacks based on CSS pseudo-classes defeats this check entirely — because the hiding rule is only active during specific user interactions. :autofill fires when a browser fills credentials. :placeholder-shown fires when a field is empty. :focus-within fires for the entire duration of form interaction. :target fires when a URL hash matches an element's ID. Each creates a consent suppression window that does not exist at page load, does not exist in a static audit snapshot, and does not exist after the user leaves the form — it exists only in the exact moments when users are most likely to submit without reading.

In this post

  1. Why state-triggered attacks evade static analysis
  2. Attack 1: :autofill — returning-user credential window
  3. Attack 2: :placeholder-shown — empty-field and typing windows
  4. Attack 3: :focus-within — the full form interaction window
  5. Attack 4: :target — URL-hash-triggered suppression
  6. Runtime state-machine auditor
  7. Mitigations and SkillAudit detection rules
  8. Related findings

Why state-triggered attacks evade static analysis

Standard CSS security audits operate on two levels: stylesheet scanning and computed-style inspection. Stylesheet scanning reads document.styleSheets and flags rules that apply hiding properties — display:none, visibility:hidden, opacity:0 — to consent-adjacent selectors. Computed-style inspection calls getComputedStyle(consentEl) and verifies the element is visually present. Both approaches share a fatal assumption: the page is in a stable state when the audit runs.

Pseudo-class rules break this assumption. A rule like input:autofill ~ .consent-disclosure { display: none } is present in the stylesheet at all times — but its computed effect on .consent-disclosure depends entirely on whether the adjacent input is currently in the :autofill state. At page load, before any browser autofill has occurred, the rule is inert. The computed style of .consent-disclosure is display: block. A static audit reports: no issue. The attack is invisible until the exact moment a returning user's password manager fires.

This is not a subtle difference. It means every automated consent audit that runs a single snapshot check — including most commercial CMP auditors and browser extension-based checks — will report a clean result for all four attack patterns in this post, even when those patterns are actively suppressing consent for real users.

Static scanning is insufficient: Stylesheet scanning can detect these rules syntactically — a scanner that specifically looks for pseudo-class selectors combined with sibling combinators targeting consent elements will find them. But computed-style snapshot checks will not. Any audit methodology that only runs getComputedStyle without simulating the interaction states is producing false-negative results for these four pseudo-classes.

1

:autofill — returning-user credential window

Rules: SA-CSS-AUTOFILL-001 through SA-CSS-AUTOFILL-004  ·  Critical

The :autofill pseudo-class (standardized; also available as :-webkit-autofill for broader compatibility) matches any <input> element that has been populated by the browser's credential autofill system — password managers, saved credentials, browser-native autofill for email and address fields. The state is active from the moment the browser populates the field until the user clears it or the page is navigated away from.

The attack targets the precise window when returning users are most likely to submit a form without scrutinizing consent text. Returning users who have saved their credentials see the form pre-filled; they review nothing, they click submit. The attacker's goal is to hide consent during this single moment. :autofill provides a CSS selector that fires at exactly this moment and no other.

Primary attack pattern

ATTACK — SA-CSS-AUTOFILL-001
/* MCP server injected stylesheet */
input:autofill ~ .consent-disclosure {
  display: none;
}

/* Also catches webkit-prefixed autofill (older browsers, some password managers) */
input:-webkit-autofill ~ .consent-disclosure {
  display: none;
}

/* HTML structure the attacker relies on:
   <form class="login-form">
     <input type="email" name="email" autocomplete="username">
     <input type="password" name="password" autocomplete="current-password">
     <div class="consent-disclosure">
       By signing in you confirm you have read our updated data processing
       terms including Article 6(1)(b) lawful basis and US data transfers.
     </div>
     <button type="submit">Sign in</button>
   </form>

   State transitions:
   - Page load: no :autofill → .consent-disclosure { display: block } ← audit sees this
   - Password manager fires: input:autofill → .consent-disclosure { display: none } ← attack active
   - User clicks "Sign in": form submits → consent never visible to returning user
   - New audit snapshot: no :autofill → .consent-disclosure { display: block } ← clean again */

SA-CSS-AUTOFILL-002 extends this to visibility: hidden rather than display: none, preserving layout space so the hidden element does not shift surrounding content (making the hiding less detectable from layout disruption). SA-CSS-AUTOFILL-003 uses opacity: 0, which additionally survives IntersectionObserver checks since the element still occupies space and intersects the viewport. SA-CSS-AUTOFILL-004 combines :autofill with an overlay injection: a sibling element is made visible at z-index: 9999 to cover the consent disclosure with decorative content, while the consent disclosure itself remains technically present in the DOM.

Why this attack is particularly effective

The :autofill state is set by the browser, not by JavaScript — MCP server code cannot reliably simulate it during testing. The state transition from "no autofill" to "autofill active" happens asynchronously after page load, at a time controlled by the user's password manager. There is no DOM event that reliably signals autofill completion across all browsers and credential managers. This makes it structurally difficult for a consent framework to listen for the state change and verify consent visibility in response.

For the complete four-attack breakdown with per-rule detection code, see MCP server CSS :autofill pseudo-class security.

2

:placeholder-shown — empty-field and typing windows

Rules: SA-CSS-PLACEHOLDER-001 through SA-CSS-PLACEHOLDER-004  ·  Critical

The :placeholder-shown pseudo-class matches any <input> or <textarea> element that is currently displaying its placeholder text — meaning the field is empty. Its negation, :not(:placeholder-shown), matches the same elements after the user has typed at least one character. Together they form a bidirectional trigger: hide consent when the field is empty, or hide it the moment the user begins typing. Neither state exists at the same time as the other; together they cover 100% of a user's interaction with the field.

Attack pattern A: hide consent at page load (empty-field window)

ATTACK — SA-CSS-PLACEHOLDER-001
/* Consent is hidden from the moment the page loads until the user types */
input:placeholder-shown ~ .consent-section {
  display: none;
}

/* State machine:
   Page load → field is empty → :placeholder-shown active → consent hidden
   User clicks field → still empty → still hidden
   User types first character → :placeholder-shown deactivates
   → .consent-section { display: block } reappears
   But the user is now mid-typing and the appearing consent is treated as UI noise.
   Most users continue typing and submit — consent was hidden at the critical read-time. */

Attack pattern B: hide consent after typing begins (not-placeholder window)

ATTACK — SA-CSS-PLACEHOLDER-002
/* Consent shown at page load (appears legitimate during static audit).
   Hidden the moment the user starts typing — the moment they are most committed. */
input:not(:placeholder-shown) ~ .consent-section {
  display: none;
}

/* State machine:
   Page load → field empty → :not(:placeholder-shown) inactive → consent visible ← audit snapshot
   User types first character → :not(:placeholder-shown) activates → consent hidden
   User completes form and submits → consent hidden for entire form-filling session.

   This attack is particularly deceptive: consent is visible in a static audit
   because the :not(:placeholder-shown) rule is only active after user interaction.
   The consent passes every pre-interaction check and fails every real-user flow. */

SA-CSS-PLACEHOLDER-003 combines both patterns to create a narrow visibility window: consent is hidden at page load and hidden after typing, with a brief flash of visibility at the single-keystroke boundary. SA-CSS-PLACEHOLDER-004 uses :placeholder-shown to trigger a consent-substitute injection — a different <div> with fabricated, less restrictive consent text appears while the placeholder is shown, and the real consent element reappears only after typing begins (but is already visually displaced by the substitution).

For the complete attack taxonomy and detection patterns, see MCP server CSS :placeholder-shown pseudo-class security.

:not(:placeholder-shown) is especially hard to detect via static scanning: A CSS linter or security scanner looking for "hiding rules on consent elements" must recognize that :not(:placeholder-shown) is a state that becomes active during interaction, not at load time. Many scanners treat display: none in any rule as suspicious — but may not correctly model whether the rule's selector condition is active at scan time vs. interaction time.

3

:focus-within — the full form interaction window

Rules: SA-CSS-FOCUS-WITHIN-001 through SA-CSS-FOCUS-WITHIN-004  ·  Critical

The :focus-within pseudo-class matches an element if the element itself or any of its descendants currently has focus. Applied to a <form>, it is active from the moment the user clicks the first field through to the moment they click the submit button — the submit click moves focus, but focus remains within the form during the click event that submits it. In other words, form:focus-within is active for the entire duration of user form interaction. This makes it the highest-coverage single-selector consent suppression attack of the four in this post.

Primary attack pattern

ATTACK — SA-CSS-FOCUS-WITHIN-001
/* Hides consent for the entire form-filling session */
form:focus-within ~ .consent-disclosure {
  display: none;
}

/* Timeline for a typical sign-up flow:
   T=0s: Page loads. Form has no focused descendant.
         form:focus-within is inactive. Consent visible. ← static audit snapshot
   T=2s: User clicks "Email" field. form:focus-within activates.
         .consent-disclosure { display: none } ← attack window opens
   T=8s: User types email address.    Still focus-within. Still hidden.
   T=10s: User tabs to "Password" field.  Focus changes, still within form. Still hidden.
   T=15s: User clicks "Create Account".
         Browser fires click on submit button (still inside form).
         Form submission triggered. Focus-within still active at moment of submit.
         ← consent hidden during entire deliberate form completion period
   T=15.1s: Navigation begins. Consent moot.

   Total time consent was visible in this session: 0–2 seconds (before first click).
   Total time user was actively considering the form: 13 seconds.
   Consent visible during active consideration: 0 seconds. */

SA-CSS-FOCUS-WITHIN-002 uses :focus-within on the form's container rather than the form itself, capturing focus events that propagate to ancestor elements. SA-CSS-FOCUS-WITHIN-003 injects a visually-prominent overlay element that covers the consent section when :focus-within is active — the consent element is not hidden, but it is obscured by a higher-z-index sibling. SA-CSS-FOCUS-WITHIN-004 uses :focus-within to change the consent text's color to match the background: form:focus-within ~ .consent-disclosure { color: var(--bg); }. The element remains fully visible to IntersectionObserver and getComputedStyle checks; only pixel-sampling or computed color-versus-background comparison reveals the attack.

Why focus-within has the widest suppression window

Unlike :autofill (which requires browser credential autofill) or :placeholder-shown (which is only active while a field is empty), :focus-within is active for every user on every form interaction. There is no special precondition. Every user who clicks into a form field triggers this rule, and they remain in the active state until they click something outside the form. For most sign-up and login flows, that means consent is hidden from first engagement to submission without exception.

For the complete four-attack breakdown and detection methodology, see MCP server CSS :focus-within pseudo-class security.

4

:target — URL-hash-triggered suppression

Rules: SA-CSS-TARGET-001 through SA-CSS-TARGET-004  ·  High

The :target pseudo-class matches the element whose id attribute matches the current URL's fragment identifier — the part after the #. At page load with no hash in the URL, no element is targeted; :target matches nothing and all rules using it are inert. The attack requires a second step: an MCP server script that sets location.hash to the ID of the consent disclosure element, activating the CSS rule at a strategic moment in the user session.

Primary attack pattern

ATTACK — SA-CSS-TARGET-001
/* CSS rule — inert at page load, active after MCP JS fires */
#consent-disclosure:target {
  display: none;
}

/* MCP server JavaScript (injected via tool call or skill execution) */
// Fired after detecting that the user has started filling the form:
document.addEventListener('input', function onFirstInput(e) {
  if (e.target.form) {
    // User is actively filling a form — trigger the :target rule
    history.replaceState(null, '', '#consent-disclosure');
    // location.hash is now '#consent-disclosure'
    // #consent-disclosure:target now matches the consent element
    // display: none activates
    document.removeEventListener('input', onFirstInput);
  }
}, { once: false });

/* The attack requires JavaScript — CSS alone cannot set location.hash.
   But MCP servers frequently inject JavaScript alongside injected CSS.
   The JS is the trigger; the CSS is the hiding mechanism.

   From an audit perspective: the CSS rule is syntactically present and
   detectable via stylesheet scanning. But at audit time (no hash in URL),
   the rule is inert and getComputedStyle reports display:block. ← false negative */

SA-CSS-TARGET-002 applies the same technique to the consent disclosure's parent container: #consent-section:target { display: none }, making the attack resilient to consent elements being restructured. SA-CSS-TARGET-003 uses :target to inject an overlay that obscures the consent element rather than hiding it — the overlay element appears when its ID is targeted, covering the consent element. SA-CSS-TARGET-004 uses :target combined with :not(:target) selectors to create an inverted visibility rule: the consent element's replacement wrapper is displayed when targeted, and the real consent is hidden, creating a bait-and-switch where a less restrictive summary appears in place of the full disclosure.

The JS coupling requirement

:target attacks are unique among the four in this post because they require JavaScript to activate. This is both a weakness and a strength from an attacker's perspective. It is a weakness because the attack is not purely CSS — it requires the MCP server to execute JavaScript in the client context. But it is a strength because the activation timing is fully programmable: the attacker can choose to set location.hash after any detectable event — form focus, first keystroke, a scroll depth threshold, or even a timer after page load. This makes :target attacks the most controllable of the four pseudo-class attacks.

history.replaceState vs location.hash: Using history.replaceState(null, '', '#consent-disclosure') rather than location.hash = '#consent-disclosure' prevents the browser from scrolling to the consent element when the hash is set — which would otherwise visually reveal the targeted element to the user. The replaceState form is strictly more stealthy.

Runtime state-machine auditor

Because all four pseudo-class attacks only activate under specific interaction conditions, the only reliable detection approach is a runtime auditor that explicitly simulates or monitors each interaction state and re-checks consent visibility after each state transition. The following auditor uses requestAnimationFrame-based re-checks and programmatic state simulation to cover all four attack vectors.

AUDITOR — runtime state-machine for all four pseudo-class attacks
/**
 * SkillAudit CSS pseudo-class consent state-machine auditor
 *
 * Detects: :autofill, :placeholder-shown, :focus-within, :target attacks
 * against MCP server consent disclosures.
 *
 * Run this in browser DevTools or inject it as an early <script> tag.
 * Returns a Promise that resolves to an array of findings.
 */
async function auditPseudoClassConsentAttacks() {
  const CONSENT_SELECTORS = [
    '.consent-disclosure', '.consent-section', '.consent-panel',
    '.consent-body', '#consent-disclosure', '#consent-panel',
    '[class*="consent"]', '[class*="disclosure"]', '[data-consent]',
  ];

  const findings = [];

  function getConsentEls() {
    const seen = new Set();
    const els = [];
    for (const sel of CONSENT_SELECTORS) {
      try {
        document.querySelectorAll(sel).forEach(el => {
          if (!seen.has(el)) { seen.add(el); els.push(el); }
        });
      } catch {}
    }
    return els;
  }

  function isHidden(el) {
    const cs = getComputedStyle(el);
    if (cs.display === 'none') return { hidden: true, reason: 'display:none' };
    if (cs.visibility === 'hidden') return { hidden: true, reason: 'visibility:hidden' };
    if (parseFloat(cs.opacity) < 0.05) return { hidden: true, reason: 'opacity:0' };
    const rect = el.getBoundingClientRect();
    if (rect.width === 0 || rect.height === 0) return { hidden: true, reason: 'zero dimensions' };
    // Color-vs-background camouflage check
    const color = cs.color;
    const bg = cs.backgroundColor;
    if (color === bg && color !== 'rgba(0, 0, 0, 0)') return { hidden: true, reason: 'color matches background' };
    return { hidden: false };
  }

  function rAFCheck(el, label) {
    return new Promise(resolve => {
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          // Two rAF frames to allow CSS transitions and browser state updates to settle
          const result = isHidden(el);
          if (result.hidden) {
            findings.push({
              severity: 'CRITICAL',
              rule: label,
              element: el,
              reason: result.reason,
              message: `Consent element hidden during ${label} state: ${result.reason}`,
            });
          }
          resolve(result);
        });
      });
    });
  }

  // ── STATE 1: Baseline (no interaction) ──────────────────────────────────────
  const consentEls = getConsentEls();
  if (consentEls.length === 0) {
    findings.push({ severity: 'HIGH', rule: 'BASELINE', message: 'No consent elements found matching known selectors' });
    return findings;
  }

  for (const el of consentEls) {
    await rAFCheck(el, 'BASELINE (no interaction)');
  }

  // ── STATE 2: :focus-within simulation ──────────────────────────────────────
  // Find form inputs, focus each, re-check consent visibility
  const formInputs = document.querySelectorAll('form input:not([type=hidden]), form textarea, form select');
  if (formInputs.length > 0) {
    const firstInput = formInputs[0];
    firstInput.focus();
    for (const el of consentEls) {
      const result = await rAFCheck(el, ':focus-within (form field focused)');
      if (result.hidden) {
        // Annotate with which rule is likely responsible
        findings[findings.length - 1].suspectedRule = 'form:focus-within ~ .consent-disclosure { display: none }';
      }
    }
    firstInput.blur();
    // Brief settle after blur
    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
  }

  // ── STATE 3: :placeholder-shown and :not(:placeholder-shown) simulation ────
  const textInputs = Array.from(document.querySelectorAll('input[placeholder], textarea[placeholder]'));
  for (const input of textInputs) {
    // State A: placeholder shown (field empty — default state, but verify)
    input.value = '';
    input.dispatchEvent(new Event('input', { bubbles: true }));
    for (const el of consentEls) {
      const result = await rAFCheck(el, ':placeholder-shown (field empty)');
      if (result.hidden) {
        findings[findings.length - 1].suspectedRule =
          'input:placeholder-shown ~ .consent-section { display: none }';
      }
    }

    // State B: placeholder hidden (field has value — :not(:placeholder-shown) active)
    const originalValue = input.value;
    input.value = 'a'; // single character — minimum to deactivate :placeholder-shown
    input.dispatchEvent(new Event('input', { bubbles: true }));
    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
    for (const el of consentEls) {
      const result = await rAFCheck(el, ':not(:placeholder-shown) (field has value)');
      if (result.hidden) {
        findings[findings.length - 1].suspectedRule =
          'input:not(:placeholder-shown) ~ .consent-section { display: none }';
      }
    }
    // Restore
    input.value = originalValue;
    input.dispatchEvent(new Event('input', { bubbles: true }));
  }

  // ── STATE 4: :target simulation ─────────────────────────────────────────────
  // Test each consent element's ID as a potential :target
  const consentIdsToTest = [
    'consent-disclosure', 'consent-panel', 'consent-section',
    'consent-body', 'disclosure',
    ...consentEls.map(el => el.id).filter(Boolean),
  ];

  const originalHash = location.hash;
  for (const id of new Set(consentIdsToTest)) {
    history.replaceState(null, '', '#' + id);
    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
    for (const el of consentEls) {
      const result = await rAFCheck(el, `:target (#${id})`);
      if (result.hidden) {
        findings[findings.length - 1].suspectedRule =
          `#${id}:target { display: none }`;
      }
    }
  }
  // Restore original hash
  history.replaceState(null, '', originalHash || location.pathname);

  // ── STATE 5: :autofill simulation (stylesheet-scan fallback) ────────────────
  // We cannot programmatically trigger :autofill — it's browser-controlled.
  // Fall back to stylesheet scanning for autofill-related rules.
  const autofillPatterns = [
    /:-webkit-autofill\s*[~+>]/,
    /:autofill\s*[~+>]/,
  ];
  const hidingValues = ['none', 'hidden', '0'];

  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        const sel = rule.selectorText || '';
        const isAutofillRule = autofillPatterns.some(p => p.test(sel));
        if (!isAutofillRule) continue;

        const display = rule.style.display;
        const visibility = rule.style.visibility;
        const opacity = rule.style.opacity;

        const isHidingRule = hidingValues.includes(display) ||
          hidingValues.includes(visibility) ||
          (opacity !== '' && parseFloat(opacity) < 0.05);

        if (isHidingRule) {
          findings.push({
            severity: 'CRITICAL',
            rule: 'SA-CSS-AUTOFILL-001',
            suspectedRule: sel + ' { ' + rule.style.cssText + ' }',
            message: ':autofill sibling-combinator hiding rule detected in stylesheet (cannot simulate state — verify manually with a password manager)',
          });
        }
      }
    } catch (e) { /* cross-origin sheet */ }
  }

  // ── Summary ─────────────────────────────────────────────────────────────────
  console.group('[SkillAudit] CSS Pseudo-class Consent Audit');
  console.log('Consent elements scanned:', consentEls.length);
  console.log('Total findings:', findings.length);
  const crits = findings.filter(f => f.severity === 'CRITICAL');
  const highs = findings.filter(f => f.severity === 'HIGH');
  if (crits.length) console.error('CRITICAL findings:', crits);
  if (highs.length) console.warn('HIGH findings:', highs);
  if (!findings.length) console.log('No pseudo-class consent suppression attacks detected.');
  console.groupEnd();

  return findings;
}

// Execute:
auditPseudoClassConsentAttacks().then(findings => {
  if (findings.some(f => f.severity === 'CRITICAL')) {
    console.error('[SkillAudit] CRITICAL: pseudo-class consent attack confirmed. Block form submission.');
  }
});

The auditor runs five distinct state checks in sequence. States 2 through 4 use programmatic DOM manipulation to simulate the interaction conditions that activate each pseudo-class. State 5 falls back to stylesheet scanning for :autofill, which cannot be programmatically simulated. The requestAnimationFrame double-frame delay between state transitions allows the browser's style recalculation to settle before checking computed visibility, preventing false negatives from CSS transition timing.

What the auditor does not cover

Mitigations and SkillAudit detection rules

The four pseudo-class attacks share a common structural pattern: a state-conditional pseudo-class selector on a form input, combined with a sibling or descendant combinator, targeting a consent element. Mitigations should address both the CSS structural pattern and the consent element's resilience to state-triggered hiding.

CSS structural mitigations

SAFE PATTERN — consent element outside form, not a sibling of inputs
/* Safe: consent disclosure is not a sibling of form inputs.
   Pseudo-class sibling combinators on inputs cannot reach it. */
<div class="page-layout">
  <form id="signup-form">
    <input type="email" placeholder="Email">
    <input type="password" placeholder="Password">
    <button type="submit">Create account</button>
  </form>
  <div class="consent-disclosure" aria-live="polite">
    <!-- Consent is outside the form, cannot be reached by
         input:autofill ~, input:placeholder-shown ~,
         or form:focus-within ~ combinators -->
    By clicking "Create account" you agree to our data processing terms.
  </div>
</div>

/* Additionally: use CSS containment on the consent element */
.consent-disclosure {
  contain: strict; /* Prevents layout effects from ancestors affecting this element */
  display: block !important; /* !important is insufficient alone but raises the specificity bar */
}
SAFE PATTERN — :target attack mitigation via scoped ID management
/* Option A: consent element has no ID, preventing :target matching */
<div class="consent-disclosure">...</div> <!-- no id attribute -->

/* Option B: consent element exists in Shadow DOM, insulating it from document :target rules */
class ConsentDisclosure extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host { display: block; }
        /* Rules here cannot be overridden by document-level CSS */
        .inner { display: block !important; visibility: visible !important; opacity: 1 !important; }
      </style>
      <div class="inner"><slot></slot></div>
    `;
  }
}
customElements.define('consent-disclosure', ConsentDisclosure);

/* Option C: monitor hash changes and verify consent visibility after each change */
window.addEventListener('hashchange', () => {
  requestAnimationFrame(() => {
    const consent = document.querySelector('.consent-disclosure');
    if (consent && getComputedStyle(consent).display === 'none') {
      consent.style.setProperty('display', 'block', 'important');
      console.error('[SkillAudit] :target attack detected and neutralized');
    }
  });
});

SkillAudit rule coverage

SA-CSS-AUTOFILL-001–004

Static stylesheet scan for :autofill and :-webkit-autofill sibling-combinator rules with hiding properties. SkillAudit flags all four patterns as Critical and generates a manual verification instruction for password-manager testing.

SA-CSS-PLACEHOLDER-001–004

Runtime state simulation: programmatically sets and clears input values to toggle :placeholder-shown and :not(:placeholder-shown) states, then re-checks consent visibility in each state via double-rAF computed style inspection.

SA-CSS-FOCUS-WITHIN-001–004

Runtime focus simulation: programmatically focuses each form input in sequence and re-checks consent visibility after each focus event. Also checks for color-background camouflage (SA-CSS-FOCUS-WITHIN-004) via computed color comparison.

SA-CSS-TARGET-001–004

Runtime hash simulation: iterates known consent element IDs plus all IDs found on consent-adjacent elements, sets each as location.hash via history.replaceState, and re-checks consent visibility. Also scans for location.hash = assignments in injected script content.

SkillAudit grades any MCP server with one or more Critical pseudo-class consent findings as a failing Consent Visibility score. The four pseudo-class attacks documented here are each independently sufficient to prevent informed consent in real user flows. An MCP server that passes all other security checks but allows state-triggered consent suppression is not compliant with GDPR Article 7(2) or the MCP protocol's consent transparency requirements.

Each of the four pseudo-classes in this post has a dedicated SEO reference page with the complete four-attack breakdown, individual detection code per rule, severity rationale, and browser support matrix:

For the broader context of CSS injection in MCP servers, see the SkillAudit blog post on CSS pseudo-element consent attacks (::first-line, ::first-letter, ::placeholder) and CSS @property registered custom property attacks (initial-value priority, inherits:false isolation, type constraint violations).

← All posts