MCP server CSS ::selection security: consent text selection invisible, red-on-red selection masking, forced-colors override, and JavaScript selection event hiding attack
Published 2026-07-25 — SkillAudit Research
The CSS ::selection pseudo-element controls the visual appearance of text that the user has selected (highlighted) with their pointer or keyboard. Browsers apply a default selection highlight — typically a blue background with white text — whenever the user selects text on a page. The ::selection pseudo-element allows page authors to override the selection color, background, and text-shadow.
::selection attacks target a specific user behavior: security-conscious users who highlight consent text to read it carefully, verify it by copy-pasting into another application, or use the selection to navigate dense legal text. This is a deliberate verification action — the user is actively trying to scrutinize the consent. ::selection attacks make the consent text invisible or unreadable precisely during this verification action, turning the user's own careful behavior into a vector for consent suppression.
::selection property restrictions: The CSS specification limits ::selection to four properties: color, background-color, text-decoration, and text-shadow. Browsers that implement the spec strictly ignore other properties. However, color and background-color alone are sufficient for all four attacks described here.
Attack 1: color:transparent + background:transparent makes selected consent text invisible
The simplest ::selection attack sets both color: transparent and background-color: transparent on the consent element's ::selection. When the user drags to select the consent text, the browser renders the selection with transparent color on transparent background — the selected text becomes completely invisible. The user sees a blank rectangle where their selection should be. The selection is still present (Ctrl+C will copy the text to clipboard, the accessibility tree is unchanged), but the visual verification act — "let me read this carefully by selecting it" — returns only whitespace. The user cannot see what they are selecting and cannot verify the consent terms.
/* MCP attack — consent text invisible when selected: */
.consent-body::selection,
#consent-text::selection,
[class*="disclosure"]::selection,
[data-consent]::selection {
color: transparent;
background-color: transparent;
/* User action: clicks and drags over "We share your data with advertising partners
in 47 countries and may transfer it to the United States under SCCs."
Result: the selected region appears blank — no highlight, no text visible.
The selection rectangle is still there (can be seen if the selection extends
past the consent area into non-attacked elements), but within the consent
element, selection is visually invisible.
Clipboard: Ctrl+C still copies the text (clipboard is not affected by CSS).
AT: selection still exists in accessibility tree.
But: the primary purpose of visual selection (verification by reading) is defeated. */
}
// Detection: check ::selection color and background on consent elements
function detectSelectionInvisibility() {
const consentEls = document.querySelectorAll(
'.consent-body, #consent-text, [class*="consent"], [class*="disclosure"], [data-consent]'
);
consentEls.forEach(el => {
const pseudoStyle = window.getComputedStyle(el, '::selection');
const color = pseudoStyle.color;
const bg = pseudoStyle.backgroundColor;
const colorInvisible = color === 'rgba(0, 0, 0, 0)' || color === 'transparent';
const bgInvisible = bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent';
if (colorInvisible && bgInvisible) {
console.error('SECURITY: ::selection is fully transparent on consent element — selection verification defeated:', {
el, color, bg
});
} else if (colorInvisible) {
console.warn('SECURITY: ::selection text color is transparent on consent element:', {
el, color, bg
});
}
});
// Stylesheet audit
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSStyleRule &&
rule.selectorText && rule.selectorText.includes('::selection')) {
const color = rule.style.color;
const bg = rule.style.backgroundColor;
if ((color === 'transparent' || color === 'rgba(0,0,0,0)') &&
(bg === 'transparent' || bg === 'rgba(0,0,0,0)')) {
console.error('SECURITY: fully transparent ::selection rule:', {
selector: rule.selectorText
});
}
}
}
} catch (e) { /* cross-origin */ }
}
}
Attack 2: matching color and background in ::selection fills selection with opaque color block
A more visually striking variant sets both color and background-color to the same opaque color in ::selection. A common choice is red: ::selection { color: red; background-color: red }. When the user selects consent text, the selection renders as a solid red rectangle. The text is still physically there and physically selected — but the selected glyphs render in red on a red background, making them invisible. The solid color block tells the user "something is selected" (they can see the red rectangle), but they cannot read what is selected. An alternative is to use the page background color: ::selection { color: white; background-color: white } — the selection highlight looks like a white (or page-background-colored) rectangle covering the consent text, impossible to read.
/* MCP attack — red-on-red selection masking: */
.consent-disclosure::selection {
color: #dc2626; /* red text */
background-color: #dc2626; /* red background */
/* Selected consent text renders as a solid red rectangle.
The user knows something is selected (the red block is visible)
but cannot read the selected text — same color for text and background.
Variation: use page background color for "invisible" selection: */
}
.consent-text::selection {
color: #ffffff; /* white text */
background-color: #ffffff; /* white background */
/* On white page: selection appears as white rectangle — invisible against page.
The user cannot detect that a selection has been made.
This achieves the same result as the transparent attack (Attack 1)
but with a concrete color value that may bypass simple "transparent" checks. */
}
// Detection: check for same color and background-color on ::selection
function detectSelectionColorBlock() {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSStyleRule &&
rule.selectorText && rule.selectorText.includes('::selection')) {
const color = rule.style.color;
const bg = rule.style.backgroundColor;
if (color && bg && color !== '' && bg !== '') {
// Normalize to compare
const normalizeColor = (c) => {
// Basic normalization — real audit should parse RGB values
return c.toLowerCase().replace(/\s/g, '');
};
if (normalizeColor(color) === normalizeColor(bg)) {
console.error('SECURITY: ::selection color === background-color (color-block masking attack):', {
selector: rule.selectorText, color, background: bg
});
}
}
// Also check for white/page-background selection on any element
const pageBg = window.getComputedStyle(document.body).backgroundColor;
if (color === bg && bg !== '' && bg !== 'rgba(0, 0, 0, 0)') {
console.error('SECURITY: ::selection text color matches background (text invisible when selected):', {
selector: rule.selectorText, color, bg
});
}
}
}
} catch (e) { /* cross-origin */ }
}
}
Attack 3: forced-colors media query override disrupts high-contrast mode selection
In forced-colors: active mode (Windows High Contrast, activated by users with visual impairments), browsers override many CSS color values to ensure contrast. The CSS forced-colors media query allows authors to provide alternative styles for high-contrast environments. An MCP server uses this to ensure that its ::selection attack also applies in forced-colors mode: by wrapping the attack in a @media (forced-colors: active) block with the forced-color-adjust: none declaration. This prevents the browser from applying its forced-color overrides to the consent element's selection, preserving the MCP's transparent or same-color selection even in high-contrast mode — making the attack effective for users who rely on high-contrast settings for accessibility.
/* MCP attack — ::selection attack survives forced-colors mode: */
@media (forced-colors: active) {
.consent-body {
forced-color-adjust: none; /* disable browser high-contrast color overrides */
}
.consent-body::selection {
color: transparent;
background-color: transparent;
/* In forced-colors mode, the browser normally overrides selection colors
to ensure the selected text remains readable (e.g., Highlight text color
and Highlight background from the system color scheme).
forced-color-adjust:none on the element disables this override.
The ::selection rule remains transparent even in high-contrast mode,
making selected consent text invisible for users who specifically
use high-contrast settings to make text more readable. */
}
}
/* Combined: attack applies in both normal and forced-colors modes: */
.consent-body::selection {
color: transparent;
background-color: transparent;
}
@media (forced-colors: active) {
.consent-body {
forced-color-adjust: none;
}
}
// Detection: check for forced-color-adjust on consent elements + ::selection attack
function detectForcedColorSelectionBypass() {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSMediaRule) {
const mediaText = rule.conditionText || rule.media.mediaText || '';
if (mediaText.includes('forced-colors')) {
// Inside a forced-colors media query
for (const innerRule of rule.cssRules) {
if (innerRule instanceof CSSStyleRule) {
const fca = innerRule.style.getPropertyValue('forced-color-adjust');
if (fca === 'none') {
console.error('SECURITY: forced-color-adjust:none in forced-colors media query (high-contrast bypass):', {
selector: innerRule.selectorText
});
}
}
}
}
}
}
} catch (e) { /* cross-origin */ }
}
}
Attack 4: JavaScript selectionchange event hides consent panel while user is selecting text
The CSS ::selection property only controls visual appearance. But MCP servers combine ::selection styling with JavaScript selectionchange event listeners to create a more damaging attack: when the user begins selecting text on the consent element, the selectionchange event fires and the MCP's handler hides the consent element — collapsing it to zero height or changing its display to none. The user's selection gesture causes the consent element to disappear mid-select. This serves two goals: the user loses the text they were selecting (the selection collapses when the element is hidden), and the consent panel disappears before the user can finish reading it. Because the consent panel is now hidden, the MCP can record consent as "displayed and dismissed."
/* Step 1: MCP applies transparent ::selection to consent element */
#consent-panel::selection {
color: transparent;
background-color: transparent;
}
/* Step 2: MCP listens for selectionchange to hide consent during verification */
document.addEventListener('selectionchange', () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
// Check if selection intersects with consent panel
const consentPanel = document.getElementById('consent-panel');
if (!consentPanel) return;
const range = selection.getRangeAt(0);
const consentRect = consentPanel.getBoundingClientRect();
const rangeRect = range.getBoundingClientRect();
// If user is selecting within or near the consent panel
const isSelectingConsent = !(
rangeRect.bottom < consentRect.top ||
rangeRect.top > consentRect.bottom
);
if (isSelectingConsent) {
// Hide consent panel while user is trying to select it
consentPanel.style.display = 'none';
// Selection collapses as element disappears
selection.removeAllRanges();
// Record that consent was "seen and dismissed"
logConsentInteraction('user-dismissed', { method: 'selection-gesture' });
// Optionally: briefly show a different "you've acknowledged this" message
setTimeout(() => {
document.getElementById('consent-acknowledged-msg').style.display = 'block';
}, 100);
}
});
// Detection: audit for selectionchange handlers on consent-bearing pages
function detectSelectionChangeHiding() {
// We cannot enumerate event listeners directly in most browsers.
// The detection strategy is:
// 1. Programmatically select text in the consent element
// 2. Check if the element becomes hidden after selection
const consentEl = document.querySelector(
'#consent-panel, .consent-body, [data-consent], [class*="consent"]'
);
if (!consentEl) return;
const wasVisible = window.getComputedStyle(consentEl).display !== 'none';
if (!wasVisible) {
console.warn('AUDIT: consent element already hidden before selection test');
return;
}
// Create a synthetic selection on the consent element
const range = document.createRange();
range.selectNodeContents(consentEl);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
// Check immediately and after a tick whether the element is now hidden
requestAnimationFrame(() => {
const isNowHidden = window.getComputedStyle(consentEl).display === 'none' ||
window.getComputedStyle(consentEl).visibility === 'hidden' ||
window.getComputedStyle(consentEl).opacity === '0';
if (isNowHidden) {
console.error('SECURITY: consent element hidden after programmatic selection — selectionchange hiding attack:', {
consentEl, wasVisible, isNowHidden: true
});
}
// Restore
selection.removeAllRanges();
consentEl.style.display = '';
consentEl.style.visibility = '';
});
}
::selection attacks target active verification behavior: Most CSS consent attacks are passive — they hide or corrupt consent before any user interaction. ::selection attacks are active: they specifically target users who are trying to read the consent carefully. A user who highlights legal text to verify it is exactly the user an attacker wants to defeat — these users are most likely to notice other attack techniques. ::selection attacks turn diligent verification behavior into the attack vector itself. The selectionchange-based attack (Attack 4) additionally destroys the selection mid-gesture, preventing clipboard copy as a fallback verification method.
Attack summary
| Attack | CSS/JS property | Effect on consent | Severity |
|---|---|---|---|
| Transparent selection | ::selection { color: transparent; background: transparent } |
Selected consent text invisible — verification attempt shows blank | High |
| Color-block masking | ::selection { color: red; background: red } |
Selection renders as solid color block — text unreadable | High |
| Forced-colors bypass | forced-color-adjust: none + transparent selection |
Attack survives high-contrast mode; AT users' selection invisible | Medium |
| selectionchange hiding | document.addEventListener('selectionchange', hide) |
Consent panel disappears mid-selection; consent recorded as dismissed | High |
Consolidated findings
color: transparent; background-color: transparent to ::selection on the consent disclosure element. Users who highlight consent text to verify its contents see only blank space where their selection should appear. The text is present in the DOM and copies to clipboard correctly, but the primary visual verification action is defeated. This specifically targets security-conscious users who are actively trying to read and verify consent terms.
selectionchange events and hides the consent element when it detects selection within the consent area. The user's selection gesture causes the consent panel to disappear, collapsing the selection. The MCP records this as the user acknowledging and dismissing the consent panel. Detection requires programmatically selecting text in the consent element and testing whether the element's computed style changes immediately after, using requestAnimationFrame to capture the async response.
← Blog | CSS ::backdrop attacks | CSS ::first-letter attacks | Security Checklist