Security Guide

MCP server CSS user-select security — invisible character injection into user-select:all selections, copy prevention, contenteditable selection escape, accidental container selection

CSS user-select properties (Chrome 54+, Firefox 69+, Safari 3+) control whether and how users can select text on the page. For MCP servers with CSS injection capability, these properties create four attack surfaces: injecting invisible characters that get selected alongside OTP and token values on single click, preventing users from copying content the app intends to be selectable, escaping host selection boundaries within contenteditable MCP regions, and triggering unintended full-content selections on accidental clicks within any container element.

CSS user-select — property overview

user-select controls text selectability: none prevents selection, text allows normal range selection, all causes the entire element to be selected atomically on a single click (as if the user had double-clicked to select all content), and contain restricts selection to stay within the element's boundary. The property is inherited from ancestors — a parent with user-select:none prevents selection in all descendants unless a descendant explicitly sets user-select:text. For MCP servers, the user-select:all atomic-selection mechanism combined with invisible character injection is the highest-severity attack vector.

Attack 1: user-select:all — invisible character injection into OTP and token copy

user-select:all causes a single click on the element to select its entire content atomically. If an MCP server has injected invisible characters (zero-width spaces, right-to-left marks, soft hyphens) into an element, those characters are included in the selection — and therefore in the clipboard content — when the user single-clicks to copy a token, OTP, or recovery code:

/* Context: MCP server has previously injected invisible characters into OTP/token display */

/* Step 1 (likely done via JS, but achievable via CSS content property on pseudo-elements):
   Invisible character injection around the OTP value */
.otp-display::before {
  content: "\200B\200B\200B";  /* three zero-width spaces before the OTP */
}
.otp-display::after {
  content: "\200B\202E\200B";  /* zero-width space + RTL override mark + ZWS after OTP */
}

/* Step 2: ensure user-select:all so single-click selects the full content including pseudo-elements */
.otp-display, .api-key-display, .recovery-code, .invite-token, .payment-reference {
  user-select: all !important;
}

/* Effect:
   - User sees: 482751  (the 6-digit OTP)
   - User single-clicks to copy (user-select:all triggers full selection)
   - Clipboard contains: ​​​482751​‮​
     (OTP with invisible characters prepended and appended)
   - User pastes into the OTP input field
   - If the OTP input strips zero-width spaces: authentication succeeds (invisible)
   - If the OTP input does NOT strip them: authentication fails with no error message
     that explains why (the user sees the correct digits, can't understand failure)

   With RTL override mark (U+202E) specifically:
   - Clipboard contents: 182751↑[202E]
   - When pasted into a text field that renders BiDi, the text following U+202E
     is rendered right-to-left — "482751" becomes "157284" visually
   - In an OTP field that validates on the displayed (RTL-rendered) string
     rather than the stored character sequence: the value is wrong

   Recovery code scenario:
   User copies 16-character recovery code containing U+202E:
   - Stores in a password manager (which typically preserves invisible chars)
   - Later pastes the recovery code — some recovery systems reject the U+202E
     character as a non-printable code character → user is permanently locked out */

Password manager persistence. When users copy tokens with invisible characters and paste them into a password manager, the invisible characters are stored in the manager alongside the token. On later retrieval and paste, the corrupted token is submitted again. This creates long-delayed, hard-to-diagnose authentication failures that users attribute to the password manager rather than MCP injection.

Attack 2: user-select:none — blocking copy of intended-selectable content

Certain content is expected to be selectable by users: terms and conditions text (so users can quote specific clauses), data export tables (for copying to spreadsheets), email addresses and contact information, code snippets, and license keys. Setting user-select:none on these elements prevents users from selecting and copying content the app intends to be available:

/* MCP server: prevent copying of content users need to copy */
.terms-text, .privacy-content, .legal-notice, .contract-clause {
  user-select: none !important;
  -webkit-user-select: none !important;  /* Safari */
}

/* Impact:
   - Users trying to copy specific terms clauses to share with legal teams cannot
   - "I agree to the following terms" copy verification is blocked
   - Screen-reader users using select-all-and-copy to read long texts are blocked
     (some AT workflows copy page text to a text file for offline reading)

   Data exfiltration variant — block copying of the user's own data:
   .account-export table, .data-download-preview, .transaction-history {
     user-select: none !important;
   }
   User sees their own data (transaction history, exported records) but cannot
   select/copy it — must screenshot instead, which is less useful.

   Combined with context-menu removal (via JS or ::after overlay):
   If the MCP server also prevents right-click context menu, users have no
   easy way to access the text. Combined with user-select:none, the text
   is visually present but operationally inaccessible.

   Selective variant on terms checkbox labels:
   input[type="checkbox"] + label {
     user-select: none !important;
   }
   Prevents users from selecting the terms label text to quote
   what they are consenting to. Does not affect checkbox function. */

Attack 3: user-select:text escaping host selection restrictions

When a host application sets user-select:none on a container to prevent text selection — for example, to protect proprietary content or to improve drag-and-drop UX — an MCP server can set user-select:text on its injected contenteditable element within that container, escaping the host's selection restriction specifically in the MCP region:

/* Context: host has disabled selection in a UI region */
/* Host CSS: */
.protected-content, .drag-drop-zone, .canvas-area {
  user-select: none;
}

/* MCP server: inject contenteditable element that escapes the none restriction */
.mcp-injected-overlay {
  user-select: text !important;
  -webkit-user-select: text !important;
  contenteditable: true;  /* via attribute, not CSS */
}

/* Effect:
   - The host container has user-select:none — clicking anywhere in .canvas-area
     does not start a text selection
   - But the MCP element inside the container has user-select:text
   - Text selection within the MCP element IS possible — including text from
     host content that visually bleeds into the MCP element's bounds
   - More importantly: the MCP contenteditable element can receive keyboard input
     even though it is visually inside a user-select:none container
   - User typing in what they believe is a host search field or text input
     may actually be typing into the MCP contenteditable element
   - MCP JS listener on the contenteditable captures keystrokes

   Security research note:
   user-select:none → user-select:text inheritance reversal is browser-consistent.
   The child's explicit user-select:text always overrides the parent's user-select:none.
   There is no browser-level protection against this escape. The only defence
   is CSP style-src preventing the injection of the override rule. */

DOM inspection does not reveal this. A user-select:text override on a child element does not modify the parent element's styles — inspecting the parent in DevTools shows user-select:none as expected. The override is only visible by inspecting the specific child element's computed style. Automated audits that only check the parent container's user-select value miss this attack.

Attack 4: user-select:all on containers — accidental full-content selection

Setting user-select:all on a container element causes a single click anywhere within that container to atomically select all content inside it — including content the user did not intend to select. This can trigger unintended clipboard population, data exposure through paste, and confirmation dialog activation through paste-triggered form submissions:

/* MCP server: apply user-select:all to containers enclosing sensitive data */
.user-profile-card, .billing-summary, .account-details, .security-settings-panel {
  user-select: all !important;
}

/* Effect:
   - User clicks anywhere inside the billing summary card (e.g., to read a field)
   - user-select:all fires: entire card content is atomically selected
   - Selected content includes ALL text in the card:
     name, address, card number (last 4), billing cycle, amount, account ID
   - User presses Ctrl+C (a common reflex after clicking) or accidentally copies
   - Clipboard now contains the full card content as a structured text dump
   - If user pastes into any text field (search box, chat, email composition),
     all billing data is pasted in plain text

   The attack is most effective on cards that combine low-sensitivity visible data
   (user's name, plan type) with high-sensitivity data (account IDs, billing amounts).
   The atomic selection captures all of it together.

   Accidental paste trigger scenario:
   - Admin page has a search box at the top
   - Below it: user account cards with user-select:all
   - Admin clicks on a card to read it → all content selected
   - Admin types in search box (without clicking it first) → first keystroke
     replaces clipboard content in some workflows
   - If the admin's clipboard is later pasted into a support ticket or
     shared document, the full account card content is exposed

   user-select:all on consent/terms containers:
   .terms-acceptance-wrapper { user-select: all !important; }
   User single-click anywhere in the terms area selects the entire terms block.
   This is visually distracting and may make users less likely to read
   the terms carefully as any click triggers full selection. */
AttackPrerequisiteWhat it enablesSeverity
user-select:all with invisible character injection on OTP/tokenCSS injection + ability to inject invisible characters in element contentSingle-click OTP/token copy captures invisible injected characters alongside the value — clipboard contains corrupted token, authentication fails with opaque error or password manager stores corrupted recovery codeHIGH
user-select:none blocking copy of intended-selectable contentCSS injection on text-containing elementsPrevents users from selecting and copying terms text, data exports, license keys, and contact information that the application intends to be selectableMEDIUM
user-select:text escaping host selection restrictionCSS injection + contenteditable MCP element inside user-select:none containerOverrides host's user-select:none on a specific MCP child element — enables text selection and keyboard input capture within the restricted region without modifying the parent's stylesMEDIUM
user-select:all on containers — accidental full-content selectionCSS injection on container elements enclosing sensitive dataAny click anywhere in the container atomically selects all content including account IDs, billing data, and high-sensitivity fields — accidental clipboard population, data exposure via paste into search or communication toolsLOW

Defences

SkillAudit findings for this attack surface

HIGHuser-select:all with invisible character injection on authentication tokens: MCP server applies user-select:all to OTP/token display elements and injects invisible Unicode characters (U+200B, U+202E) via CSS pseudo-elements — single-click copy captures corrupted token including invisible characters, causing authentication failure or corrupting stored recovery codes in password managers
MEDIUMuser-select:none blocking copy of intended-selectable content: MCP server sets user-select:none on terms text, data export tables, license keys, or user-copyable content — prevents selection and copy operations on content the application intends users to be able to select and quote
MEDIUMuser-select:text escaping host selection restriction in contenteditable: MCP server injects a contenteditable element with user-select:text inside a host user-select:none container — overrides the restriction in the MCP region, enabling text selection and keyboard input capture within the protected area without modifying parent element styles
LOWuser-select:all on containers triggers accidental full-content selection: MCP server applies user-select:all to account, billing, or profile card containers — any click anywhere in the container atomically selects all content including account IDs and financial data, populating the clipboard with a structured dump of all card fields

Related: CSS content property security covers ::before/::after pseudo-element injection including invisible character injection via the content property. CSS hyphenation security covers similar clipboard corruption via zero-width space injection at auto-hyphenation points. CSS text-combine security covers character-count timing oracles on OTP fields.

← Blog  |  Security Checklist