Security reference · CSS injection · Pseudo-class attacks · Media attribute selectors
MCP server CSS :muted and :volume-locked pseudo-class security
The CSS :muted pseudo-class selects media elements where audio output is muted — either via the muted attribute in HTML or via the HTMLMediaElement.muted property set to true in JavaScript. For MCP server consent attacks, this is a near-universal trigger: demo videos on install pages are routinely muted to satisfy browser autoplay policies. The :not(:muted) variant inverts this, hiding consent when a user unmutes the demo video to hear the narration — the moment of maximum engagement. Four attack patterns including :volume-locked for kiosk contexts are documented below.
CSS :muted attack surface in MCP consent UIs
| CSS pattern | Why it fires | Evasion quality |
|---|---|---|
video:muted ~ .consent-disclosure { display:none } | Muted attribute applied for autoplay compliance; fires from page load on any muted video | Muted videos are a browser-compliance best practice; trigger is invisible as a policy artifact |
:muted ~ .consent-section { display:none } | Fires on any muted media element — audio or video; hidden muted audio elements are near-zero-detection triggers | Any muted media element qualifies; MCP can inject a single hidden muted audio element as trigger |
video:not(:muted) ~ .consent { display:none } | :not(:muted) fires when user unmutes the video; consent hidden at peak engagement moment | Consent disappears when user does the most natural thing — turns on sound to hear the demo narration |
:volume-locked ~ .consent { display:none } | :volume-locked fires in kiosk/restricted browser contexts; check applicability to MCP sandbox environments | Narrow attack surface but deterministic in applicable environments; CSS-only, zero injection |
Attack 1: video:muted fires from page load on muted demo video
Browser autoplay policy requires video to be muted for auto-play without a user gesture. MCP install pages that include a product demo video typically add the muted attribute for exactly this reason — to allow the video to play automatically. video:muted fires on any such video from the moment the page loads, before any user interaction:
/* Malicious CSS — SA-CSS-MUTED-001 */
/* Fires on any muted video element — a near-universal state on install pages */
video:muted ~ .consent-disclosure {
display: none;
}
/* Malicious HTML injection */
<video src="/assets/demo.mp4" autoplay muted playsinline loop></video>
<div class="consent-disclosure">
This server will have read access to your workspace files...
</div>
/* Why :muted is an excellent trigger:
1. Muted attribute is a browser-compliance best practice for autoplay videos
2. The muted state is stable throughout normal user sessions
(most users never unmute demo videos during installation)
3. The video can be positioned off-screen; :muted still fires
4. audio elements with the muted attribute or property also trigger :muted */
function detectMutedConsentHiding() {
const findings = [];
for (const media of document.querySelectorAll('video:muted, audio:muted')) {
let sib = media.nextElementSibling;
while (sib) {
if (sib.matches('[class*="consent"], .terms-section') &&
getComputedStyle(sib).display === 'none') {
findings.push({ id: 'SA-CSS-MUTED-001', severity: 'high',
message: `${media.tagName} is :muted; consent sibling is hidden` });
}
sib = sib.nextElementSibling;
}
}
return findings;
}
Policy compliance as camouflage: The muted attribute on a video element is a browser-required practice for autoplay — not a suspicious signal. Security reviewers who see a muted video on an install page typically interpret it as responsible development. The CSS rule that weaponizes :muted is in the injected stylesheet, not in any HTML attribute that would draw scrutiny.
Attack 2: Any muted media element as trigger — hidden audio
The :muted pseudo-class applies to both <video> and <audio> elements. An MCP server can inject a hidden, muted audio element — a sound effect placeholder, an ambient audio preload, or simply an empty element with muted — as a zero-interaction trigger for the generic :muted ~ .consent-section { display:none } rule:
/* Malicious CSS — SA-CSS-MUTED-002 */
/* Fires on any muted media element — audio elements are near-invisible */
:muted ~ .consent-section {
display: none;
}
/* Malicious HTML — hidden muted audio element as trigger */
<audio muted style="position:absolute;width:0;height:0;opacity:0"></audio>
<section class="consent-section">
<p>By continuing, you grant access to your browser storage...</p>
</section>
/* The hidden muted audio element:
- Has no src attribute — no network request, no auditing trail in DevTools network panel
- Is zero-sized and opacity:0 — not visible in a visual audit
- Its only function is to be :muted — the CSS selector does the rest
- Can use style="display:none" — :muted still fires on display:none media elements */
Attack 3: video:not(:muted) fires when user unmutes
This is the highest-evasion variant: video:not(:muted) ~ .consent { display:none } hides consent when the video is unmuted. At page load, the video is muted (and the consent is visible). When the user clicks the unmute button to hear the demo narration — the most natural user action on a demo-video install page — :not(:muted) fires and consent disappears:
/* Malicious CSS — SA-CSS-MUTED-003 */
/* Fires when user unmutes the demo video to hear narration */
video:not(:muted) ~ .consent {
display: none;
}
/* Why this is the most sophisticated :muted attack:
1. At page load: video is muted → :muted fires → :not(:muted) is false → consent VISIBLE
(initial state is safe; a spot-check at load passes)
2. User unmutes video: :muted becomes false → :not(:muted) fires → consent HIDDEN
(attack activates at peak engagement, when user is most distracted by demo)
3. User re-mutes video: :muted becomes true → :not(:muted) false → consent reappears
(if tested after the fact, consent is visible again — no evidence remains)
The attack window is the duration the user is watching with sound on.
A static auditor who loads the page and checks visibility at load finds nothing wrong.
A dynamic auditor who does not simulate the unmute action also finds nothing.
/* Detection: simulate unmute action and check consent visibility */
async function detectNotMutedConsentHiding() {
const results = [];
for (const vid of document.querySelectorAll('video')) {
if (!vid.muted) continue; // only check currently-muted videos
// Simulate unmute
vid.muted = false;
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
// Check if any consent element was hidden by the unmute
const hiddenConsent = [...document.querySelectorAll('[class*="consent"], .terms-section')]
.filter(el => getComputedStyle(el).display === 'none');
if (hiddenConsent.length > 0) {
results.push({ id: 'SA-CSS-MUTED-003', severity: 'critical',
message: 'Consent hidden when video unmuted — video:not(:muted) attack suspected' });
}
// Restore muted state
vid.muted = true;
}
return results;
}
Testing gap: SA-CSS-MUTED-003 is completely invisible to auditors who only test at page load. The attack requires a dynamic test that simulates the user's natural unmute action. Static analysis of the CSS rule shows video:not(:muted) with display:none — a pattern that should be flagged — but pure static analysis without load-state context cannot know whether the video starts muted.
Attack 4: :volume-locked in kiosk and restricted browser contexts
The :volume-locked pseudo-class, pioneered in Safari and referenced in CSS Selectors 4, selects media elements in contexts where the user cannot change the volume — kiosk mode browsers, restricted enterprise environments, and some mobile WebViews. An MCP deployed in such a context can use :volume-locked ~ .consent { display:none } as a deterministic trigger that is completely opaque in standard development environments:
/* Malicious CSS — SA-CSS-MUTED-004 */
/* :volume-locked fires in kiosk/restricted contexts where volume is locked */
:volume-locked ~ .consent {
display: none;
}
/* Why :volume-locked matters for enterprise MCP deployments:
- Enterprise browsers (Citrix virtual desktop, kiosk terminals) often lock volume
- Mobile WebViews in some kiosk apps lock volume at the system level
- The MCP server that targets enterprise team installs (via the Team plan)
can rely on the volume-locked state of corporate kiosk environments
- Standard development environments never see :volume-locked fire —
the attack is invisible to developers testing on their own machines
/* Browser support (as of 2026):
- Safari 15.4+: yes (spec author implementation)
- Chrome: no (under consideration)
- Firefox: no (under consideration)
The attack surface is currently Safari-focused, but the draft spec
anticipates broader adoption. Enterprise Safari deployments on
managed Apple devices are a realistic target today. */
SkillAudit findings for CSS :muted/:volume-locked consent attacks
video:muted ~ .consent-disclosure { display:none }. Muted video for autoplay compliance; :muted fires from page load. Trigger is a browser-compliance best practice; detection requires CSS rule scan correlating :muted with display:none on consent siblings.:muted ~ .consent-section { display:none }. Hidden muted audio element injected as trigger; audio elements are near-invisible in visual and basic DOM audits. Zero network request if no src attribute.video:not(:muted) ~ .consent { display:none }. Consent hidden when user unmutes video — peak engagement moment. Completely invisible at page load; requires dynamic unmute simulation to detect. Consent reappears when user re-mutes; no trace remains after the fact.:volume-locked ~ .consent { display:none }. Fires in kiosk/enterprise environments where volume is locked. Invisible to developers on standard machines; deterministic in enterprise deployment contexts. Currently Safari-focused but draft spec targets wider implementation.Detection and safe patterns
/**
* SkillAudit: CSS :muted/:volume-locked consent attack detection
* Covers SA-CSS-MUTED-001 through -004
*/
async function auditMutedVolumeConsentAttacks() {
const results = [];
// Scan CSS for :muted and :volume-locked rules affecting consent
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
if (/(:(muted|volume-locked))/.test(rule.selectorText) &&
/display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0/.test(rule.style.cssText)) {
results.push({ id: 'SA-CSS-MUTED-001', severity: 'high',
message: `CSS rule hides element via :muted/:volume-locked: ${rule.selectorText}` });
}
}
} catch (_) {}
}
// Dynamic probe: unmute videos and check consent visibility (SA-CSS-MUTED-003)
for (const vid of document.querySelectorAll('video')) {
if (!vid.muted) continue;
const consentBefore = [...document.querySelectorAll('[class*="consent"]')]
.filter(el => getComputedStyle(el).display !== 'none').length;
vid.muted = false;
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const consentAfter = [...document.querySelectorAll('[class*="consent"]')]
.filter(el => getComputedStyle(el).display !== 'none').length;
vid.muted = true;
if (consentAfter < consentBefore) {
results.push({ id: 'SA-CSS-MUTED-003', severity: 'critical',
message: `Unmuting video reduced visible consent elements from ${consentBefore} to ${consentAfter}` });
}
}
return results;
}
/* Safe patterns:
1. Never use :muted or :not(:muted) as consent visibility conditions
2. Place consent in shadow DOM — :muted in the light DOM cannot affect shadow subtrees
3. If you include demo video, inject it after the consent section in DOM order
4. Test consent visibility with video in both muted and unmuted states */
Related MCP consent attack research
- CSS :playing and :paused media state consent attacks
- CSS :fullscreen and :popover-open document-state attacks
- CSS :hover interaction-triggered consent hiding
- CSS Pseudo-Class Attacks on MCP Consent (overview)
Audit your MCP server for :muted consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-MUTED findings.