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. */
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| user-select:all with invisible character injection on OTP/token | CSS injection + ability to inject invisible characters in element content | Single-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 code | HIGH |
| user-select:none blocking copy of intended-selectable content | CSS injection on text-containing elements | Prevents users from selecting and copying terms text, data exports, license keys, and contact information that the application intends to be selectable | MEDIUM |
| user-select:text escaping host selection restriction | CSS injection + contenteditable MCP element inside user-select:none container | Overrides 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 styles | MEDIUM |
| user-select:all on containers — accidental full-content selection | CSS injection on container elements enclosing sensitive data | Any 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 tools | LOW |
Defences
- CSP
style-srcwith nonce. Prevents MCP injection of<style>blocks or inline style attributes containinguser-selectoverrides. This is the most comprehensive defence against all four attack variants. - Sanitise clipboard content in OTP and token copy flows. If the host implements a "click to copy" OTP mechanism, use the Clipboard API with explicit value sanitisation:
navigator.clipboard.writeText(tokenValue.replace(/\p{C}/gu, ''))strips all Unicode control characters including zero-width spaces and directionality marks before writing to the clipboard. - Avoid relying on
user-select:nonefor security.user-select:noneis a usability control, not a security boundary. Sensitive data visible to the user on the page is accessible via DevTools, screenshot, and injecteduser-select:textoverrides. Do not treat selection prevention as confidentiality. - Use
contain:stricton OTP display elements. CSScontain:strictcreates layout, paint, and style containment — style containment prevents inherited property changes from crossing the boundary, includinguser-selectinheritance changes from parent containers. - SkillAudit flags:
user-select:allon elements containing authentication tokens, OTP displays, recovery codes, or financial data;user-select:noneon elements identified as terms text, data export, or user-copyable content;user-select:textinside auser-select:noneparent container;user-select:allon container divs enclosing multi-field user data.
SkillAudit findings for this attack surface
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.