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 / Chromium | No — unknown pseudo-class | No — entire rule dropped | Auditor in Chrome: rule never fires |
| Firefox | No — unknown pseudo-class | No — entire rule dropped | Auditor in Firefox: rule never fires |
| -webkit-any() (old WebKit alias) | Yes in older Safari/WebKit | Yes in those versions only | Only 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 pattern | Why it fires in Safari | Why it evades Chrome auditors |
|---|---|---|
:matches(div, p, section, article) ~ .consent { display:none } | Broad union matches all common content elements in Safari | Chrome 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 Safari | Chrome 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 Safari | Chrome drops; Safari-only activation makes it interaction-triggered consent hiding |
| Cross-browser gap exploitation | :matches() is functionally :is() in Safari; full Selectors 4 union support | Unknown 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