Security reference · CSS injection · User interaction · Consent verification bypass

MCP server CSS user-select consent security

CSS user-select: none prevents users from selecting text in an element. The consent text remains fully visible — display:block, opacity:1, normal font size — but users cannot select it to copy, verify against a reference, or translate via a browser extension. In MCP install flows, consent text specifies what permissions the skill is claiming, what data it accesses, and what external calls it makes. Blocking selection prevents copy-paste verification against the skill's stated documentation, disables translation workflows for non-native language users, and breaks accessibility tools that rely on text selection. Four attack patterns: blanket selection prevention, asymmetric interaction modeling, JS clipboard hijacking, and the non-CSS selectstart event alternative.

user-select attack surface in MCP consent flows

AttackConsent still readableCopy-paste worksTranslation worksScreen reader reads it
user-select: noneYesNoNo (extension-dependent)Yes
JS copy event hijackYesNo (wrong text)No (wrong text)Yes
selectstart preventionYesNoNoYes
user-select: all on button onlyYesNo for consentNo for consentYes

user-select: none is commonly used legitimately: Many UI components apply user-select: none to buttons, toolbars, and interactive controls to prevent accidental text selection during clicks. This creates plausible deniability — a consent area with user-select: none can be argued to be a copy of the same pattern applied to the entire install dialog for "UX" reasons. Auditors must specifically flag user-select: none applied to text-content elements containing consent language, not interactive controls.

Attack 1: user-select: none on consent text — readable but non-copyable

The simplest form applies user-select: none directly to the consent text container. The consent language is fully visible, font size and color are normal, and the element is properly positioned in the viewport. But every attempt by the user to select the text fails — the cursor does not change to the text selection cursor, and click-drag produces no selection highlight:

/* Malicious CSS — SA-CSS-USEL-001 */
.mcp-consent-text,
.mcp-consent-disclosure,
.mcp-permission-grant-notice {
  /* user-select: none prevents all text selection:
     - click and drag: no selection highlight appears
     - triple-click: no word/paragraph selection
     - Ctrl+A / Cmd+A: selects other page content, skips this element
     - cursor over text: shows default cursor, not text cursor (|)
  */
  user-select: none;
  -webkit-user-select: none; /* required for Safari */

  /* All standard checks still pass:
     display:block, visibility:visible, opacity:1,
     font-size normal, color normal, in viewport, non-zero dimensions */
}

/* Impact on consent verification workflows:
   1. The user cannot Ctrl+C to copy the permission scope description to a note
   2. Browser translate extensions that rely on text selection cannot translate
   3. Users who habitually select text while reading to track position cannot
   4. Password managers or autofill that scrape selected text for context fail
   5. Users cannot paste consent text into a search to verify it against documentation */

/* Benign user-select:none (legitimate): */
.install-button { user-select: none; } /* Correct: prevents accidental selection on button click */

/* Hostile user-select:none (this attack): */
.consent-text-paragraph { user-select: none; } /* Wrong: prevents consent verification */

/* Detection: */
function detectUserSelectNone() {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install|permission/i;
  const INTERACTIVE = /^(BUTTON|INPUT|SELECT|TEXTAREA|A)$/;
  for (const el of document.querySelectorAll('*')) {
    if (INTERACTIVE.test(el.tagName)) continue; /* skip legitimate interactive elements */
    if (!CONSENT.test(el.textContent?.substring(0, 400) || '')) continue;
    const userSelect = getComputedStyle(el).userSelect ||
                       getComputedStyle(el).webkitUserSelect;
    if (userSelect === 'none') {
      findings.push({ id: 'SA-CSS-USEL-001', severity: 'medium',
        message: `Consent-text element "${el.tagName.toLowerCase()}" has user-select: none — text cannot be selected or copied by the user, preventing copy-paste verification of consent terms.` });
    }
  }
  return findings;
}

Attack 2: asymmetric user-select model — install button selectable, consent not

An asymmetric user-select pattern applies user-select: none to the entire install dialog, then restores it with user-select: text on the install button label only. This creates a model where the button label is accidentally selectable (users who triple-click the Install button can select "Install") but the consent text is not. The asymmetry is subtle and could be argued as accidental, but it consistently prevents consent verification:

/* Malicious CSS — SA-CSS-USEL-002 */
.mcp-install-dialog {
  user-select: none; /* entire dialog non-selectable */
}

.mcp-install-btn {
  /* Re-enables selection on the button label only */
  /* This creates an asymmetric model:
     - Install button text: selectable (user-select: text override)
     - Consent disclosure text: NOT selectable (inherits user-select: none) */
  user-select: text;
}

/* Why this is asymmetric in the wrong direction:
   For UX purposes, the install button should be user-select:none (prevents accidental
   selection during double-click to trigger install).
   The consent text SHOULD be user-select:text (allows verification).
   This pattern inverts the correct behavior — interactive controls are selectable,
   disclosure text is not. */

/* The dialog also has user-select:none applied to form inputs to make the pattern
   appear intentional and UI-focused rather than consent-targeting: */
.mcp-install-dialog input,
.mcp-install-dialog label {
  user-select: none; /* appears to be "prevent accidental selection on form interaction" */
}
/* But input values and labels are short technical strings, not disclosure language.
   The practical impact of user-select:none on form labels vs. on consent paragraphs
   is entirely different from a consent-verification perspective. */

Attack 3: JS clipboard hijacking via copy event — displayed text differs from copied text

Even when user-select allows text selection, JavaScript can intercept the copy event and replace the clipboard content with different text. A user who selects the consent paragraph, presses Ctrl+C, and pastes into a document or translation tool receives entirely different content than what was displayed on screen. The consent terms shown are not the terms stored in the clipboard:

/* JS — SA-CSS-USEL-003 */
/* No CSS needed — the attack operates entirely at the clipboard API level */

document.querySelector('.mcp-consent-disclosure').addEventListener('copy', (e) => {
  /* Intercept the copy event before the default browser clipboard write */
  e.preventDefault();

  /* Replace the selected text with different content */
  const FAKE_CONSENT = 'This skill reads file metadata and responds to chat queries.';
  /* The actual consent text may say:
     "This skill reads, modifies, and deletes files in your home directory,
      sends file contents to external servers, and executes shell commands." */

  e.clipboardData.setData('text/plain', FAKE_CONSENT);
  /* The clipboard now contains the benign version, not the actual consent */
});

/* Subtler variant: copy only the first sentence */
document.querySelector('.mcp-consent-text').addEventListener('copy', (e) => {
  const selected = window.getSelection().toString();
  /* If the user selected more than one sentence, truncate to just the first */
  if (selected.split('.').length > 1) {
    e.preventDefault();
    const firstSentence = selected.split('.')[0] + '.';
    e.clipboardData.setData('text/plain', firstSentence);
    /* User thinks they copied the full consent; clipboard has only the innocuous opener */
  }
});

/* Detection: */
/* 1. Check if the consent element has a 'copy' event listener (difficult without
      access to the event listener registry)
   2. Programmatically select and copy the consent text, then verify the clipboard
      content matches the element's textContent */
async function detectClipboardHijack() {
  const consentEl = document.querySelector('[class*="consent"]');
  if (!consentEl) return;
  /* Select all text in the consent element */
  const range = document.createRange();
  range.selectNodeContents(consentEl);
  const sel = window.getSelection();
  sel.removeAllRanges();
  sel.addRange(range);
  /* Trigger a copy */
  document.execCommand('copy');
  /* Read clipboard (requires Clipboard API permission) */
  try {
    const clipText = await navigator.clipboard.readText();
    const domText = consentEl.textContent.trim();
    if (clipText.trim() !== domText) {
      return { id: 'SA-CSS-USEL-003', severity: 'critical',
        message: 'Clipboard content after copying consent text does not match the displayed text — copy event is being hijacked and replacing consent with different content.' };
    }
  } catch { /* clipboard permission denied — cannot verify */ }
}

Attack 4: selectstart event prevention — non-CSS selection blocking

The DOM selectstart event fires when a text selection is about to begin. Calling e.preventDefault() in the handler cancels the selection before it starts — no CSS property is needed. This achieves the same effect as user-select: none but leaves no CSS fingerprint. A stylesheet scan for user-select finds nothing; only an event listener audit or behavioral test reveals the attack:

/* JS — SA-CSS-USEL-004 */
/* No CSS — pure JS event prevention */

document.querySelector('.mcp-consent-area').addEventListener('selectstart', (e) => {
  /* Cancels the selection before it starts */
  e.preventDefault();
  /* Result: same as user-select: none — cursor changes to text cursor on hover,
     but click-drag produces no selection, triple-click selects nothing */
});

/* Propagation-based variant: */
document.querySelector('.mcp-install-dialog').addEventListener('selectstart', (e) => {
  /* If event target is within the consent area, prevent; otherwise allow */
  if (e.target.closest('[class*="consent"]')) {
    e.preventDefault();
  }
  /* This looks like a dialog-level selectstart handler, not a consent-specific one */
});

/* Detection: behavioral test */
function detectSelectStartPrevention() {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access/i;
  for (const el of document.querySelectorAll('*')) {
    if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
    /* Attempt a programmatic selection */
    const range = document.createRange();
    range.selectNodeContents(el);
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
    /* Check if selection actually contains text */
    if (sel.toString().trim() === '' && el.textContent.trim() !== '') {
      findings.push({ id: 'SA-CSS-USEL-004', severity: 'medium',
        message: `Programmatic selection of consent-text element "${el.className}" produced empty selection despite non-empty textContent. selectstart event may be prevented by JS event listener — no CSS user-select property will be found in a stylesheet audit.` });
    }
    sel.removeAllRanges();
  }
  return findings;
}

user-select: none and copy event hijacking are compounding attacks: user-select: none prevents manual selection-based copy, while a copy event hijack targets users who manage to select text via keyboard shortcuts (Ctrl+A inside the element). Together, they close both the mouse-selection and keyboard-selection verification paths. An audit must test both the CSS property and the clipboard behavior independently, as each attack can be present without the other.

SkillAudit findings for CSS user-select consent attacks

MediumSA-CSS-USEL-001 — Consent-text element has user-select: none. Text is visible but cannot be selected or copied, preventing users from copy-pasting consent terms for verification, translation, or comparison against documentation. Distinct from legitimate user-select: none on interactive controls.
MediumSA-CSS-USEL-002 — Asymmetric user-select model: install dialog has user-select: none with user-select: text override only on the install button. Consent text inherits none; the button label is selectable. Inverts the correct UX pattern where interactive controls should be non-selectable and disclosure text should be selectable.
CriticalSA-CSS-USEL-003 — JavaScript copy event handler replaces clipboard content on copy from the consent area. Displayed consent text differs from what is written to the clipboard. Copy-paste verification workflows receive sanitized or truncated consent, not the actual disclosure text shown in the UI.
MediumSA-CSS-USEL-004 — JavaScript selectstart event handler cancels selection in the consent area. Produces the same effect as user-select: none but leaves no CSS fingerprint. Only detectable by programmatically testing whether text selection succeeds or by auditing event listeners on consent-area elements.

Related MCP consent attack research

SkillAudit's consent audit checks the computed user-select property on all consent-text elements, tests programmatic selection success, and verifies that clipboard content after a programmatic copy matches the displayed text — catching both CSS and JS-based consent text selection attacks. Paste your MCP server URL at skillaudit.dev to scan for SA-CSS-USEL findings.