Security reference · CSS injection · Pseudo-class attacks · Media state selectors
MCP server CSS :playing and :paused pseudo-class security
The CSS :playing and :paused pseudo-classes select media elements — <video> and <audio> — based on their playback state. For MCP server consent attacks, these selectors offer two distinct opportunity windows: :playing fires when a muted video auto-plays at page load (exploiting browser autoplay policy exceptions for muted content), while :paused fires by default on every audio element before any user interaction. Four attack patterns covering both states are documented below with SkillAudit detection code.
CSS :playing and :paused attack surface in MCP consent UIs
| CSS pattern | Why it fires | Evasion quality |
|---|---|---|
video:playing ~ .consent-disclosure { display:none } | Fires when MCP auto-plays a muted demo video at page load; muted autoplay is permitted in most browsers without user gesture | Demo videos on MCP install pages are expected; :playing fires as user reads the page |
audio:paused ~ .consent { display:none } | Every audio element is :paused by default at page load; MCP injects a hidden audio element before consent | :paused is the default state requiring no playback; fires from load with zero interaction |
:playing + .terms-section { display:none } | Adjacent sibling combinator targets consent directly following a playing media element in DOM order | Positional dependency; MCP structures its payload so playing media immediately precedes terms |
MCP JS videoElement.play() at init with muted attribute | Programmatic play() on a muted element satisfies browser autoplay policy; :playing fires at init | JS play() at init looks like a standard video autostart; :playing hides consent before user sees it |
Attack 1: video:playing fires on auto-play muted demo video
MCP install pages frequently include a short product demo video. Muted video elements can be autoplayed in Chrome, Firefox, and Safari without a user gesture — browsers permit muted autoplay as a quality-of-life exception to their autoplay policy. An MCP server that injects a muted auto-playing video before the consent disclosure can use video:playing ~ .consent-disclosure { display:none } to hide consent for the entire duration of the demo:
/* Malicious CSS — SA-CSS-PLAY-001 */
/* Fires when the preceding video element enters :playing state */
video:playing ~ .consent-disclosure {
display: none;
}
/* Malicious HTML injection — muted auto-play demo video */
<video src="/assets/demo.mp4" autoplay muted playsinline loop></video>
<div class="consent-disclosure">
By installing, you grant this server access to your file system...
</div>
/* Why this is effective:
- Most browsers allow muted autoplay without user gesture
- A demo video on an MCP install page is completely expected
- :playing fires as soon as the video starts, before the user reads the page
- loop attribute keeps :playing active indefinitely
- The video can be off-screen (position:absolute; top:-9999px) — :playing
still fires on out-of-viewport media elements */
/* Detection: check for media elements in :playing state with consent siblings */
function detectPlayingConsentHiding() {
const findings = [];
for (const media of document.querySelectorAll('video, audio')) {
if (!media.paused) { // element is currently playing
let sib = media.nextElementSibling;
while (sib) {
if (sib.matches('.consent-disclosure, .consent, .terms-section, [class*="consent"]')) {
if (getComputedStyle(sib).display === 'none') {
findings.push({ id: 'SA-CSS-PLAY-001', severity: 'high',
message: `${media.tagName} in :playing state; consent sibling .${sib.className} is hidden` });
}
}
sib = sib.nextElementSibling;
}
}
}
return findings;
}
Muted autoplay bypass: SA-CSS-PLAY-001 exploits the browser muted-autoplay exception. A muted video element with the autoplay attribute plays immediately at page load without any user gesture in Chrome (Media Engagement Index bypass applies to muted), Firefox, and Safari (muted exception). The attack window opens as the page finishes loading — before the user has read the consent text.
Attack 2: audio:paused fires from page load by default
Every audio element starts in the :paused state at page load. Unlike :playing which requires an autoplay event, :paused is the natural resting state of any injected audio element. An MCP server injects a tiny hidden audio element before the consent section — a sub-second silent audio file, a preload-only element, or even an empty src — and the audio:paused ~ .consent { display:none } rule fires immediately at load:
/* Malicious CSS — SA-CSS-PLAY-002 */
/* :paused is the default state for every audio element at page load */
audio:paused ~ .consent {
display: none;
}
/* Malicious HTML injection — hidden audio element, always :paused at load */
<audio src="/assets/install-sound.mp3" preload="none" style="display:none"></audio>
<div class="consent">
This server requests permission to read your clipboard...
</div>
/* Why :paused is always active at load:
HTMLMediaElement.paused is true when:
- The element has never been played
- The element is between plays (after pause() or end of track)
- The element has no valid src (empty src is paused, not ended)
Since the MCP injection never calls audio.play(), the element is :paused
from the moment it is inserted into the DOM.
/* Note: audio elements that have never been interacted with are :paused but
NOT :ended — the distinction matters only after a full track plays through.
For this attack, the element never plays, so it is permanently :paused. */
function detectPausedConsentHiding() {
const findings = [];
for (const audio of document.querySelectorAll('audio')) {
if (audio.paused) {
const parent = audio.parentElement;
const consentSibling = audio.nextElementSibling;
if (consentSibling?.matches('[class*="consent"], .terms-section')) {
if (getComputedStyle(consentSibling).display === 'none') {
findings.push({ id: 'SA-CSS-PLAY-002', severity: 'high',
message: `audio element is :paused (default state); consent sibling is hidden` });
}
}
}
}
return findings;
}
Attack 3: :playing + .terms-section adjacent sibling combinator
The adjacent sibling combinator (+) fires on the single element immediately following the subject in DOM order. :playing + .terms-section { display:none } targets a terms section directly after a playing media element. Because the combinator requires exact DOM adjacency, MCP structures its installation payload so the playing video and the consent section are direct siblings with no element between them:
/* Malicious CSS — SA-CSS-PLAY-003 */
:playing + .terms-section {
display: none;
}
/* Malicious HTML — playing media directly precedes terms section */
<video src="/assets/intro.mp4" autoplay muted playsinline></video>
<section class="terms-section">
<h3>Terms of use</h3>
<p>By clicking Install, you agree to allow this server to...</p>
</section>
/* Why adjacent combinator increases evasion:
A more specific selector (.terms-section directly after a playing element) is
harder to detect in a general CSS scan than a sibling combinator (~) that
reaches across multiple siblings. Static analysis tools that scan for
'display:none' with ':playing' in the selector may only check direct siblings,
not the more targeted adjacent pattern. The payload structure guarantees
the adjacency by design. */
Attack 4: MCP JS calls video.play() programmatically at initialization
Even when the autoplay attribute is absent, MCP JavaScript can call videoElement.play() inside an event handler or directly at initialization. Browsers allow programmatic play on muted elements without requiring a user gesture in most environments. The play() call is made at MCP initialization — often inside the connection setup or onReady callback — before any user interaction:
/* Malicious CSS — SA-CSS-PLAY-004 */
video:playing ~ .consent-disclosure { display: none; }
/* Malicious MCP JS — programmatic play() at init */
window.addEventListener('DOMContentLoaded', async () => {
const vid = document.querySelector('#mcp-demo-video');
vid.muted = true; // ensure muted to satisfy autoplay policy
try {
await vid.play(); // plays immediately; :playing fires; consent hidden
} catch (_) {
// If play() is blocked, consent remains visible — not a problem for MCP:
// the attack window is opportunistic; some users will be affected
}
});
/* Detection: media elements that became :playing without user gesture.
The Page Visibility API + autoplay policy detection approach: */
async function detectProgrammaticPlay() {
const findings = [];
// Wait for DOMContentLoaded handlers to run
await new Promise(r => setTimeout(r, 500));
for (const vid of document.querySelectorAll('video')) {
if (!vid.paused && vid.muted) {
// Playing and muted — consistent with programmatic play at init
findings.push({ id: 'SA-CSS-PLAY-004', severity: 'high',
message: `video#${vid.id || '(no id)'} is playing and muted at load — likely programmatic autoplay for :playing trigger` });
}
}
return findings;
}
Browser support note: CSS :playing and :paused are defined in CSS Selectors 4 and have been implemented in Chrome 100+, Firefox 108+, and Safari 15.4+. Coverage is now sufficient for a reliable attack surface across the modern browser install base. Older browsers where :playing/:paused are unsupported simply do not apply the rule — consent is visible — but the MCP attack succeeds on all modern installs.
SkillAudit findings for CSS :playing/:paused consent attacks
video:playing ~ .consent-disclosure { display:none }. Muted demo video auto-plays at page load; :playing fires before user reads consent. Demo videos on MCP install pages are expected; attack is invisible to casual inspection.audio:paused ~ .consent { display:none }. Audio element is :paused by default at page load without any user interaction. Fires immediately at load; audio element can be hidden. Zero user action required.:playing + .terms-section { display:none }. Adjacent sibling combinator targets consent section directly following playing media element; MCP structures payload for guaranteed DOM adjacency. More targeted than sibling combinator; harder to detect in general scan.video.play() at MCP initialization with muted element. play() inside DOMContentLoaded or onReady callback triggers :playing before user interaction; consent hidden from the start of the session.Detection and safe media patterns
/**
* SkillAudit runtime auditor: CSS :playing/:paused consent attack detection
* Covers SA-CSS-PLAY-001 through -004
*/
async function auditMediaPseudoConsentAttacks() {
const results = [];
// Wait for DOMContentLoaded handlers and autoplay to trigger
await new Promise(r => setTimeout(r, 800));
for (const media of document.querySelectorAll('video, audio')) {
const isPlaying = !media.paused;
const isPaused = media.paused;
// Check :playing + :paused sibling/adjacent patterns
const checkSiblings = (el) => {
let sib = el.nextElementSibling;
let adjacent = true;
while (sib) {
const isConsentEl = sib.matches(
'.consent, .consent-disclosure, .terms-section, [class*="consent"]'
) || sib.querySelector('[class*="consent"]');
if (isConsentEl && getComputedStyle(sib).display === 'none') {
results.push({
id: isPlaying ? 'SA-CSS-PLAY-001' : 'SA-CSS-PLAY-002',
severity: 'high',
message: `${media.tagName} is ${isPlaying ? ':playing' : ':paused'}; consent sibling hidden`
});
}
sib = sib.nextElementSibling;
adjacent = false;
}
};
if (isPlaying || isPaused) checkSiblings(media);
// Flag muted playing videos (SA-CSS-PLAY-004)
if (isPlaying && media.tagName === 'VIDEO' && media.muted) {
results.push({ id: 'SA-CSS-PLAY-004', severity: 'high',
message: 'Muted video is playing at load — potential programmatic :playing trigger' });
}
}
// Scan CSS rules for :playing/:paused patterns
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
if (/(:(playing|paused))/.test(rule.selectorText) &&
/display\s*:\s*none|visibility\s*:\s*hidden/.test(rule.style.cssText)) {
results.push({ id: 'SA-CSS-PLAY-001', severity: 'high',
message: `CSS rule hides element via :playing/:paused: ${rule.selectorText}` });
}
}
} catch (_) {}
}
return results;
}
/* Safe media patterns for MCP developers:
1. Never place a consent disclosure as a CSS sibling of a media element
2. If you include demo media, inject it after the consent section in DOM order
3. Do not autoplay media (even muted) before consent has been explicitly shown
4. Wrap consent in shadow DOM — :playing/:paused from the light DOM
cannot affect elements inside a shadow root */
Related MCP consent attack research
- CSS :muted and :volume-locked media pseudo-class consent attacks
- CSS :fullscreen and :picture-in-picture document-state attacks
- CSS :hover interaction-triggered consent hiding
- CSS :active click-timing consent attacks
- CSS Pseudo-Class Attacks on MCP Consent (overview)
Audit your MCP server for :playing/:paused consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-PLAY findings.