Security reference · CSS injection · Pseudo-class attacks · Cross-browser compatibility gaps

MCP server CSS :matches() selector security

The CSS :matches() pseudo-class was the original draft name for what became :is() in the finalized CSS Selectors 4 specification. While Chrome, Firefox, and Edge dropped :matches() in favor of :is(), Safari continued parsing :matches() as a legacy alias. This cross-browser gap creates a consent-hiding attack surface unique to MCP servers targeting Safari users: a CSS rule using :matches() is silently dropped as an unknown pseudo-class by Chrome and Firefox (which apply the "ignore entire rule" behavior for unknown selectors), but fires normally in Safari. Chrome-based auditing tools never see the rule fire — making it a browser-specific consent attack that passes all standard audits.

:matches() browser support matrix — the consent-hiding gap

Browser:matches() parsed?CSS rule with :matches() fires?Audit tool sees the rule fire?
Safari (all current versions)Yes — legacy alias for :is()Yes — identical behavior to :is()No — if auditor runs in Chrome/Headless
Chrome / ChromiumNo — unknown pseudo-classNo — entire rule droppedAuditor in Chrome: rule never fires
FirefoxNo — unknown pseudo-classNo — entire rule droppedAuditor in Firefox: rule never fires
-webkit-any() (old WebKit alias)Yes in older Safari/WebKitYes in those versions onlyOnly detectable in WebKit-based test runners

Audit blind spot: Because almost all automated security scanning tools use Chromium-based headless browsers (Puppeteer, Playwright with Chromium, etc.), a CSS rule using :matches() to hide consent is completely invisible to the auditor. The rule is dropped by Chrome at parse time — it never enters the cascade, never affects computed style, and never appears in any Chrome DevTools computed style panel.

CSS :matches() attack surface in MCP consent UIs

CSS patternWhy it fires in SafariWhy it evades Chrome auditors
:matches(div, p, section, article) ~ .consent { display:none }Broad union matches all common content elements in SafariChrome drops rule as unknown; consent visible in Chromium auditors
:matches(.mcp-loaded, [data-ready]) .consent { display:none }Union of MCP class and attribute; fires once either is applied in SafariChrome never applies this rule; MCP class triggers it in Safari only
:matches(:hover, :focus, :active) ~ .consent-disclosure { display:none }Interaction pseudo-class union fires on any user interaction in SafariChrome drops; Safari-only activation makes it interaction-triggered consent hiding
Cross-browser gap exploitation:matches() is functionally :is() in Safari; full Selectors 4 union supportUnknown pseudo-class in Chrome/Firefox — entire rule block ignored at parse time

Attack 1: :matches(div, p, section) — broad union targeting all content elements

In Safari, :matches(div, p, section, article) is parsed as an alias for :is(div, p, section, article) and matches any of those four elements. Combined with a general sibling combinator, this rule hides consent whenever any of those common elements precedes the consent disclosure in the DOM — which is the case on virtually every real page:

/* Malicious CSS — SA-CSS-MATCHES-001 */
/* :matches() is :is() in Safari; not parsed in Chrome/Firefox */
:matches(div, p, section, article) ~ .consent {
  display: none;
}

/* Browser-specific behavior:
   Safari: Parses as :is(div, p, section, article) ~ .consent { display:none }
           Any div, p, section, or article before .consent hides it immediately.
           All real pages have these elements → consent hidden from page load.

   Chrome: ":matches" is unknown pseudo-class → entire rule block is ignored.
           consent is visible normally.
           A Chrome-based headless auditor runs, checks consent visibility → PASSES.
           The auditor never realizes the rule exists for Safari users.

/* CSS Selectors 4 rule for unknown pseudo-classes:
   "If a pseudo-class is unknown, the selector containing it is invalid."
   "Invalid selectors are simply ignored — they do not cause any error."
   → Chrome silently ignores the entire rule. No parse error. No warning in DevTools.
   → The rule is completely absent from Chrome's computed cascade.

/* Detection in Safari environment: */
function detectMatchesBreadthAttack() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const sel = rule.selectorText || '';
        // In Safari, :matches() is parsed and appears as selectorText
        if (/:matches\(/.test(sel) &&
            /consent|disclosure|terms|privacy/i.test(sel)) {
          findings.push({ id: 'SA-CSS-MATCHES-001', severity: 'critical',
            message: `Safari-only :matches() rule hiding consent: "${sel}" — invisible to Chrome-based auditors` });
        }
      }
    } catch (_) {}
  }
  return findings;
}

/* Cross-browser detection (must also scan stylesheet TEXT, not parsed rules): */
async function detectMatchesInStylesheetText() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      const href = sheet.href;
      if (href) {
        const text = await fetch(href).then(r => r.text());
        if (/:matches\(/.test(text)) {
          findings.push({ id: 'SA-CSS-MATCHES-001', severity: 'critical',
            message: `:matches() found in stylesheet text "${href}" — will fire in Safari even if invisible to Chrome auditor` });
        }
      }
    } catch (_) {}
  }
  return findings;
}

Attack 2: :matches(.mcp-loaded, [data-ready]) — MCP-controlled class and attribute union

This attack uses a union of an MCP-controlled class name and an MCP-controlled data attribute. In Safari, the rule fires as soon as either condition is satisfied. Since MCP controls both (it adds the class and sets the data attribute via JavaScript), it chooses the timing. In Chrome/Firefox the rule is ignored entirely, so the attack is Safari-only:

/* Malicious CSS — SA-CSS-MATCHES-002 */
/* Union of MCP class and MCP attribute — either triggers hiding in Safari */
:matches(.mcp-loaded, [data-ready]) .consent {
  display: none;
}

/* Injection: MCP adds class and data attribute to its container element */
<div class="mcp-component" id="mcp-root">
  <div class="consent">Data access disclosure...</div>
</div>

/* JavaScript — MCP sets class, which triggers the :matches() rule in Safari */
document.getElementById('mcp-root').classList.add('mcp-loaded');
// or: document.getElementById('mcp-root').dataset.ready = 'true';
// Either condition triggers :matches() in Safari → .consent inside is hidden

/* The dual-condition union provides redundancy:
   - If the class path is detected and removed, the attribute path still works
   - If the attribute is stripped, the class path works
   - Both can be set simultaneously for guaranteed activation

/* Safari-only means: Mac and iOS Safari users see no consent disclosure.
   iOS Safari accounts for all browser rendering on iPhones (Apple mandates WebKit
   on iOS). Safari-only attacks are therefore also iOS-only attacks — any iPhone
   user visiting the MCP install page will have consent hidden. */

function detectMCPMatchesUnionAttack() {
  const findings = [];
  // Scan stylesheet text for :matches() — the only reliable cross-browser method
  const styleEls = [...document.querySelectorAll('style')];
  for (const style of styleEls) {
    if (/:matches\(/.test(style.textContent) &&
        /consent|disclosure|terms|privacy/i.test(style.textContent)) {
      findings.push({ id: 'SA-CSS-MATCHES-002', severity: 'critical',
        message: `:matches() with consent-targeting selector found in inline