Security reference · CSS injection · Pseudo-class attacks · Document-state selectors
MCP server CSS :fullscreen, :picture-in-picture, and :popover-open pseudo-class security
CSS document-state pseudo-classes — :fullscreen, :picture-in-picture, and :popover-open — match elements based on browser-level UI state rather than structural or interaction conditions. These pseudo-classes fire when a video enters fullscreen, when a media element enters picture-in-picture mode, or when an element with the popover attribute becomes visible. MCP servers exploit these states in two ways: passively, by writing CSS rules that fire when users interact with demo videos or embedded media on the installation page; and actively, by calling APIs like element.requestFullscreen() or popover show methods programmatically at initialization to trigger consent hiding before user interaction. Four attack patterns are documented below, covering :fullscreen, html:has(:fullscreen), :popover-open, and programmatic fullscreen request.
CSS document-state pseudo-class attack surface in MCP consent UIs
| CSS pattern | Trigger condition | Evasion quality |
|---|---|---|
:fullscreen ~ .consent-disclosure { display:none } | Any element that enters fullscreen mode — video player, demo embed, presentation iframe — activates this rule and hides following consent | Fullscreen demo videos on install pages are common UX; :fullscreen rules look like fullscreen layout adjustments |
html:has(:fullscreen) .consent-section { display:none } | Document-level rule fires globally when any fullscreen element exists, regardless of DOM position — hides consent anywhere on the page | html:has() rules look like document-level theme or layout switching; the fullscreen condition is a legitimate use case |
:popover-open ~ .consent { display:none } | MCP injects an element with the popover attribute and calls showPopover() at initialization — :popover-open fires immediately, hiding consent | Popovers are legitimate UI components; MCP's injected popover can be invisible (opacity:0, off-screen) while still triggering :popover-open |
| MCP JS calls element.requestFullscreen() | Programmatic fullscreen request at initialization — requires a user gesture in most browsers, but MCP can embed a gesture hook inside a required setup interaction | Fullscreen request inside an "initialize MCP" step looks like a demo or tutorial feature |
Attack 1: :fullscreen fires when users interact with demo video embeds
Installation pages for MCP servers often include demo videos, tutorial embeds, or capability showcase media. When a user clicks fullscreen on any such video, the video element enters the CSS :fullscreen state. An MCP CSS rule targeting :fullscreen ~ .consent-disclosure can hide the consent section that appears after the video in the DOM — and the consent stays hidden for the duration of the fullscreen session:
/* Malicious CSS injection — SA-CSS-FULL-001 */
/* Fires when any element enters fullscreen mode */
:fullscreen ~ .consent-disclosure {
display: none;
}
/* Example installation page structure: */
<section class="install-page">
<div class="demo-video">
<video controls src="demo.mp4"></video>
<!-- User clicks fullscreen → video element enters :fullscreen state -->
<!-- :fullscreen ~ .consent-disclosure fires on the sibling consent div -->
</div>
<div class="consent-disclosure">
By installing, you grant: file system read/write, network access...
</div>
</section>
/* Vendor-prefixed variants that must also be checked: */
:-webkit-full-screen ~ .consent-disclosure { display: none; }
:-moz-full-screen ~ .consent-disclosure { display: none; }
:-ms-fullscreen ~ .consent-disclosure { display: none; }
/* Duration of attack:
The user enters fullscreen to watch the demo → consent is hidden while
they watch. When the demo finishes and they exit fullscreen (Esc key),
consent reappears — but if they already clicked "Install" within the
fullscreen demo's context (MCP overlays the Install button in fullscreen),
the acceptance has already been recorded.
/* :fullscreen for layout adjustments is legitimate:
:fullscreen { background: #000; padding: 0; }
:fullscreen .controls { position: fixed; bottom: 0; }
Adding a sibling consent-hiding rule is a single-line addition.
/* Detection: any :fullscreen rule with hiding properties */
function checkFullscreenRule() {
const findings = [];
const fullscreenPatterns = [':fullscreen', ':-webkit-full-screen', ':-moz-full-screen', ':-ms-fullscreen'];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
const sel = rule.selectorText;
if (!fullscreenPatterns.some(p => sel.includes(p))) continue;
const s = rule.style;
if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;
findings.push({
id: 'SA-CSS-FULL-001', severity: 'high', selector: sel,
message: `:fullscreen hiding rule — consent hidden while user watches demo video in fullscreen`
});
}
} catch (_) {}
}
return findings;
}
Demo-assisted hiding: The :fullscreen attack is unique in that it encourages the user to watch a demo video before installing — because watching the demo (entering fullscreen) is what triggers consent hiding. An MCP server that says "Watch our demo, then install" is structuring the user flow to guarantee activation of the :fullscreen rule.
Attack 2: html:has(:fullscreen) hides consent globally via document-level rule
While :fullscreen as a sibling combinator requires the fullscreen element to precede consent in the DOM, combining it with html:has() produces a document-level rule that fires globally — hiding consent anywhere on the page whenever any element is in fullscreen, regardless of DOM position:
/* Malicious CSS injection — SA-CSS-FULL-002 */
/* Document-level: fires globally when ANY element is in fullscreen */
html:has(:fullscreen) .consent-section {
display: none;
}
/* How this escapes the sibling combinator limitation:
SA-CSS-FULL-001 requires: [fullscreen-element] ~ .consent-disclosure
(consent must be a sibling AFTER the fullscreen element in DOM)
SA-CSS-FULL-002 uses html:has() to lift the rule to document level:
Any :fullscreen element anywhere in the document triggers the rule,
and .consent-section can be anywhere in the document as a descendant of html.
/* This means consent can be:
- In a separate section below the video ✓
- In a fixed overlay above the video ✓
- In a sidebar panel ✓
- In a modal dialog ✓
All locations of consent are hidden when fullscreen is active.
/* html:has() rules for legitimate fullscreen UX:
html:has(:fullscreen) .navbar { display: none; } — hide nav in fullscreen
html:has(:fullscreen) .sidebar { opacity: 0; } — hide sidebar
html:has(:fullscreen) body { overflow: hidden; } — lock scroll
The consent-hiding variant follows the exact same pattern.
/* :has() with other document-state selectors: */
html:has(:picture-in-picture) .consent-section { display: none; }
/* Fires when any media enters picture-in-picture mode
html:has(:popover-open) .consent-section { display: none; }
/* Fires when any popover element is visible
/* Detection: html:has() rules combining fullscreen/pip/popover with hiding properties */
function checkHasFullscreenDocumentRule() {
const findings = [];
const stateSelectors = [':fullscreen', ':-webkit-full-screen', ':picture-in-picture', ':popover-open'];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
const sel = rule.selectorText;
if (!sel.includes(':has(')) continue;
if (!stateSelectors.some(p => sel.includes(p))) continue;
const s = rule.style;
if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;
findings.push({
id: 'SA-CSS-FULL-002', severity: 'critical', selector: sel,
message: `html:has() document-level state rule — hides consent globally during fullscreen/pip/popover`
});
}
} catch (_) {}
}
return findings;
}
Attack 3: :popover-open exploits MCP-injected invisible popover elements
The CSS :popover-open pseudo-class matches elements that have the popover attribute and are currently in the "showing" state. Popovers are shown via element.showPopover() or via popovertarget button attributes. MCP can inject an invisible popover element — positioned off-screen, with zero opacity, or with zero dimensions — call showPopover() at initialization, and use the resulting :popover-open state to trigger consent hiding via a CSS sibling or :has() rule:
/* Malicious CSS injection — SA-CSS-FULL-003 */
/* Fires when any popover element is in the "showing" state */
:popover-open ~ .consent {
display: none;
}
/* Malicious MCP JavaScript — inject and show invisible popover */
// MCP injects a popover element that is visually invisible:
const popover = document.createElement('div');
popover.setAttribute('popover', '');
popover.id = 'mcp-status';
popover.style.cssText = [
'position:fixed',
'top:-9999px',
'left:-9999px',
'width:1px',
'height:1px',
'opacity:0',
'pointer-events:none'
].join(';');
// Insert before consent in the DOM to enable sibling combinator:
const consent = document.querySelector('.consent');
if (consent) {
consent.parentNode.insertBefore(popover, consent);
}
// Show the popover — triggers :popover-open → consent hidden:
popover.showPopover();
/* Why an invisible popover looks legitimate:
Popovers are used for toast notifications, tooltips, and status messages.
An "MCP initialization status" popover that is styled to be invisible
appears to be a background status tracking mechanism. The :popover-open
CSS rule that hides consent in a sibling element is the attack vector.
/* :popover-open for legitimate UI components: */
.notification:popover-open { animation: slideIn 0.2s; }
.tooltip:popover-open { opacity: 1; visibility: visible; }
/* MCP's rule follows the same pattern but with a sibling combinator:
:popover-open ~ .consent { display: none; }
/* Popover-open detection: check for injected elements with popover attribute */
function checkPopoverOpenRule() {
const findings = [];
// Static CSS scan
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
if (!rule.selectorText.includes(':popover-open')) continue;
const s = rule.style;
if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;
findings.push({
id: 'SA-CSS-FULL-003', severity: 'high', selector: rule.selectorText,
message: `:popover-open hiding rule — check for MCP-injected invisible popover elements`
});
}
} catch (_) {}
}
// Check for invisible shown popovers
const popovers = document.querySelectorAll('[popover]');
for (const p of popovers) {
if (!p.matches(':popover-open')) continue;
const cs = getComputedStyle(p);
const isInvisible = cs.opacity === '0' || parseFloat(cs.width) < 2 || parseFloat(cs.height) < 2
|| parseFloat(cs.top) < -100 || parseFloat(cs.left) < -100;
if (isInvisible) {
findings.push({
id: 'SA-CSS-FULL-003', severity: 'critical',
popoverId: p.id || p.className,
message: `Invisible open popover detected — may be triggering :popover-open consent-hiding rule`
});
}
}
return findings;
}
Self-contained attack: The :popover-open attack is fully self-contained — MCP injects both the trigger element and the CSS rule, and shows the popover programmatically. Unlike attacks that rely on finding a suitable trigger on the host page, this pattern works on any page regardless of existing DOM structure.
Attack 4: MCP JS calls requestFullscreen() to trigger :fullscreen at initialization
Modern browsers require a user gesture (click, keypress) to honor element.requestFullscreen() calls — this is a security measure to prevent sites from stealing the full screen without user consent. However, MCP can embed the fullscreen request inside a user gesture that the user expects to perform as part of installation setup, making the fullscreen request appear to be a product feature rather than an attack setup:
/* Malicious CSS injection — SA-CSS-FULL-004 (paired with JS) */
:fullscreen .consent-disclosure,
html:has(:fullscreen) .consent-disclosure {
display: none;
}
/* Malicious MCP JavaScript — fullscreen request inside a setup gesture */
// MCP presents a "Launch Setup" button that the user must click to proceed:
const setupBtn = document.createElement('button');
setupBtn.textContent = 'Launch MCP Setup →';
setupBtn.className = 'btn-primary';
document.querySelector('.install-section').prepend(setupBtn);
setupBtn.addEventListener('click', async () => {
// User gesture (button click) allows requestFullscreen:
const setupContainer = document.querySelector('.setup-container');
if (setupContainer && setupContainer.requestFullscreen) {
try {
await setupContainer.requestFullscreen();
// .setup-container is now :fullscreen → consent-disclosure is hidden
// MCP can now show its install flow in fullscreen with consent invisible
} catch (_) {}
}
});
/* The user sees: a "Launch MCP Setup" button that opens a fullscreen install wizard
The attack: the fullscreen state hides consent while MCP requests permissions
/* Why fullscreen install flows look legitimate:
Some desktop applications request fullscreen during installation for
immersive UI. An MCP server that says "Launch our interactive setup wizard"
and opens fullscreen is not obviously malicious — the missing piece is
that consent is hidden during the fullscreen session.
/* Programmatic fullscreen detection: */
async function checkProgrammaticFullscreen() {
const findings = [];
let fullscreenActivated = false;
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement) {
fullscreenActivated = true;
// Check if consent is hidden after fullscreen entry
requestAnimationFrame(() => {
const consentEls = document.querySelectorAll('[class*="consent"], .disclosure, .terms');
for (const el of consentEls) {
if (getComputedStyle(el).display === 'none') {
findings.push({
id: 'SA-CSS-FULL-004', severity: 'critical',
fullscreenElement: document.fullscreenElement.tagName + (document.fullscreenElement.id ? '#' + document.fullscreenElement.id : ''),
message: `Fullscreen entry hides consent — :fullscreen rule activated by ${document.fullscreenElement.tagName} entering fullscreen`
});
}
}
});
}
}, { once: false });
// Also check for :fullscreen CSS rules
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
if (!rule.selectorText.includes('fullscreen') && !rule.selectorText.includes('full-screen')) continue;
const s = rule.style;
if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;
findings.push({
id: 'SA-CSS-FULL-004', severity: 'high', selector: rule.selectorText,
message: `:fullscreen hiding rule — watch for programmatic fullscreen requests in MCP initialization`
});
}
} catch (_) {}
}
return findings;
}
SkillAudit findings for CSS document-state pseudo-class consent attacks
:fullscreen ~ .consent-disclosure { display:none }. Fires when the user enters fullscreen on any demo video or media embed on the installation page. The demo-then-install UX flow guarantees activation of this rule if the user watches the demo before accepting. Also fires on vendor-prefixed variants (:-webkit-full-screen, :-moz-full-screen). Detection: any :fullscreen rule with hiding properties.html:has(:fullscreen) .consent-section { display:none }. Document-level rule hides consent globally regardless of DOM position when any element is in fullscreen. Escapes the sibling-combinator limitation of SA-CSS-FULL-001. Same pattern applies with :picture-in-picture and :popover-open. Detection: html:has() rules combining state pseudo-classes with hiding properties.:popover-open ~ .consent { display:none } combined with MCP JS injecting an invisible popover and calling showPopover() at initialization. Self-contained attack — MCP provides both the trigger element and the CSS rule. The invisible popover is indistinguishable from a legitimate background status tracker. Detection: :popover-open CSS rules with hiding properties + invisible shown popover elements check.element.requestFullscreen() inside a required setup interaction, using the user's click gesture to satisfy the browser's user-gesture requirement for fullscreen. Consent-hiding :fullscreen rules activate for the duration of the fullscreen session. Detection: fullscreenchange event listener checking consent visibility after fullscreen entry + static :fullscreen CSS rule scan.Detection and safe consent patterns
/**
* SkillAudit runtime auditor: CSS document-state pseudo-class consent attack detection
*/
async function auditDocumentStateConsentAttacks() {
const results = [];
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const stateSelectors = [
':fullscreen', ':-webkit-full-screen', ':-moz-full-screen', ':-ms-fullscreen',
':picture-in-picture', ':popover-open'
];
// Phase 1: static CSS scan
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
const sel = rule.selectorText;
const s = rule.style;
if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;
for (const stateSel of stateSelectors) {
if (!sel.includes(stateSel)) continue;
const isDocumentLevel = sel.startsWith('html:has') || sel.startsWith(':root:has');
let id = 'SA-CSS-FULL-001';
let severity = 'high';
if (isDocumentLevel) { id = 'SA-CSS-FULL-002'; severity = 'critical'; }
else if (stateSel === ':popover-open') { id = 'SA-CSS-FULL-003'; severity = 'high'; }
results.push({ id, severity, selector: sel,
message: `${stateSel} hiding rule — document-state pseudo-class consent attack` });
break;
}
}
} catch (_) {}
}
// Phase 2: check for invisible shown popovers (SA-CSS-FULL-003)
for (const p of document.querySelectorAll('[popover]')) {
if (!p.matches(':popover-open')) continue;
const cs = getComputedStyle(p);
if (parseFloat(cs.opacity) < 0.01 || parseFloat(cs.width) < 2 || parseFloat(cs.height) < 2) {
results.push({ id: 'SA-CSS-FULL-003', severity: 'critical',
popoverId: p.id || p.className || 'unnamed',
message: `Invisible :popover-open element — likely used to trigger :popover-open consent-hiding CSS rule` });
}
}
// Phase 3: fullscreen change monitoring (SA-CSS-FULL-004)
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) return;
requestAnimationFrame(() => {
for (const el of document.querySelectorAll('[class*="consent"], .disclosure, .terms')) {
if (getComputedStyle(el).display === 'none') {
results.push({ id: 'SA-CSS-FULL-004', severity: 'critical',
element: document.fullscreenElement.tagName,
message: `Fullscreen entry causes consent to be hidden — :fullscreen rule activated` });
}
}
});
}, { once: true });
/* Safe consent patterns against document-state attacks:
1. Do not include video elements or media embeds on the same page as the
consent disclosure — separate the demo and consent steps
2. If a demo video must be on the consent page, inject consent via shadow DOM:
shadow DOM consent cannot be targeted by external :fullscreen or :has() rules
3. Monitor for fullscreenchange events and verify consent visibility after entry:
if consent is hidden during fullscreen, re-show it programmatically
and flag the session as suspicious
4. Audit your MCP bundle for calls to requestFullscreen() and showPopover() —
these APIs should not be called during the consent flow initialization */
return results;
}
Related MCP consent attack research
- CSS :modal pseudo-class consent attacks
- CSS :has() relational pseudo-class consent attacks
- CSS :target pseudo-class consent attacks
- CSS :scope pseudo-class consent attacks
- CSS Pseudo-Class Attacks on MCP Consent (overview)
Audit your MCP server for :fullscreen, :picture-in-picture, and :popover-open consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-FULL findings with document-state trigger analysis.