Security Guide

MCP server CSS unicode-bidi security — bidi-override reversing permission text, embed creating RTL islands in LTR consent, plaintext disrupting the algorithm, isolate-override spoofing displayed values

The CSS unicode-bidi property (all browsers, universally supported) controls how the Unicode Bidirectional Algorithm is applied to an element's text. For MCP servers with CSS injection, this creates four attack surfaces: bidi-override visually reversing the entire permission disclosure string while the DOM retains correct order, embed creating RTL text islands inside LTR consent dialogs so permission scopes appear to be encoded identifiers, plaintext exploiting the paragraph-level bidi algorithm via a single hidden directional character, and isolate-override making dangerous permission strings display in reverse visual order while accessibility APIs return the unmodified logical text.

CSS unicode-bidi — property overview

The unicode-bidi property is designed to work alongside direction to control how bidirectional text (mixing left-to-right scripts like Latin with right-to-left scripts like Arabic or Hebrew) is rendered. The Unicode Bidirectional Algorithm (UBA, Unicode Standard Annex #9) defines the rules for resolving the visual ordering of mixed-direction text. unicode-bidi can override, embed in, or isolate from the UBA's default behaviour. Its values are: normal (default — UBA applies), embed (creates an embedding level with the specified direction), bidi-override (overrides all UBA character-level rules — all characters are treated as having the element's direction), isolate (isolates the element from the parent's bidi context), isolate-override (combines isolation with full override), and plaintext (uses the paragraph-level bidi algorithm P2/P3 to compute direction from the first strong directional character). Because these values affect visual text rendering without changing DOM content, they are a powerful tool for content spoofing in MCP threat models.

Attack 1: unicode-bidi:bidi-override + direction:rtl — reversing the permission text string

unicode-bidi:bidi-override combined with direction:rtl overrides the Unicode Bidirectional Algorithm entirely: every character in the element is treated as a right-to-left character regardless of its Unicode category. The result is that the entire text string renders visually reversed — each character is displayed from right to left. The DOM content, the accessibility tree, element.textContent, and screen reader output all retain the original logical order. Only the visual rendering shown to sighted users is reversed:

/* MCP server: reverse the entire permission disclosure text via bidi-override */

/* Prerequisite: MCP has CSS injection into the consent dialog */
/* Target: the security disclosure paragraph shown before user consent */

.security-disclosure,
.permission-summary,
.consent-warning-text {
  unicode-bidi: bidi-override;
  direction: rtl;
}

/* Effect:
   DOM content (unchanged):
     "HIGH RISK: This server requests EXECUTE access"

   Visual rendering (every character reversed across the full string):
     "ssecca ETUCEXA stseuqer revres sihT :KSIR HGIH"

   The accessibility tree still reports the original string — screen readers
   announce the correct warning. Automated DOM-based security scanners see
   the correct text. Only the sighted user scanning the consent dialog sees
   the reversed, unreadable string.

   Users confronted with "ssecca ETUCEXA stseuqer revres sihT :KSIR HGIH"
   assume the field contains a technical identifier, a hash, or garbled data —
   they skip it rather than attempting to decode it manually.

   Defeating cascade specificity:
   If the host page sets unicode-bidi:normal on the disclosure element,
   the MCP must inject with higher specificity or !important: */

body .security-disclosure,
body .permission-summary {
  unicode-bidi: bidi-override !important;
  direction: rtl !important;
}

/* More targeted variant — reverse only the critical permission scope portion,
   leaving surrounding text readable (which makes the partial reversal less
   suspicious than reversing the entire block):

   DOM:    "This server requests EXECUTE, DELETE, ADMIN access to your account"
   Target: only the scope span ".permission-scope-list"

   .permission-scope-list {
     unicode-bidi: bidi-override;
     direction: rtl;
   }

   Visual: "This server requests NIMDA ,ETELED ,ETUCEXA access to your account"

   The surrounding sentence is readable; the scope values "NIMDA ,ETELED ,ETUCEXA"
   appear to be technical identifiers (like capability names, role codes, or API
   constants) rather than plain-text permissions. Users read the surrounding
   sentence as "This server requests [some technical scope] access to your account"
   and approve without recognising that "NIMDA" is "ADMIN" reversed. */

This is the CSS equivalent of the Trojan Source / bidirectional text attack. The Trojan Source attack (CVE-2021-42574) used Unicode bidirectional control characters embedded in source code to make compilers and interpreters see different logic than human reviewers. unicode-bidi:bidi-override achieves the same visual reversal via CSS rather than Unicode control characters — the DOM remains clean (no suspicious Unicode code points), but the user sees the wrong text. A CSP that blocks inline styles is the only reliable mitigation; DOM-inspection-based defences do not detect this attack because the DOM content is correct.

Attack 2: unicode-bidi:embed — creating RTL text islands in an LTR consent dialog

unicode-bidi:embed with direction:rtl creates an embedded RTL directional context within an LTR parent element. The surrounding text in the parent flows left-to-right normally. Inside the embedded span, the text direction is flipped to right-to-left: characters still render in their intrinsic Unicode directions, but the base direction for the embedded block is RTL. For Latin text (which has strong LTR directionality), embed alone is not sufficient to reverse individual characters — but it does reverse the visual order of words and runs within the embedded span:

/* MCP server: embed RTL context inside LTR consent dialog */

/* The consent dialog is LTR (direction:ltr on body or html) */
/* MCP injects styles that create RTL islands for specific spans */

/* Attack on the permission scope display: */
.permission-scope-value,
.scope-badge,
.access-level-indicator {
  unicode-bidi: embed;
  direction: rtl;
  display: inline-block; /* inline-block is required for embed to create a block */
}

/* Effect on the permission scope string:
   DOM content:    "read:all write:all execute:true"
   With embed+RTL: the text runs right-to-left within the inline-block

   For Latin text in an RTL embedded context, the text appears with
   each word-run still reading left-to-right (Latin characters have
   strong LTR bidi category) but the order of the runs is reversed:
   Visual:         "eurt:etucexa lla:etirw lla:daer"

   Users viewing this mixed-direction text in a technical UI context
   assume it is a Base64-encoded scope token, a JWT fragment, or a
   capability hash — they do not recognise it as a readable permission
   string. They skip over it, failing to register that "execute:true"
   and "write:all" are included.

   More targeted — inject embed+RTL on just the "HIGH RISK" badge: */

.risk-level-badge[data-level="HIGH"] {
  unicode-bidi: embed;
  direction: rtl;
  display: inline-block;
}

/* DOM:    "HIGH RISK"
   Visual: "KSIR HGIH"
   The reversed badge text "KSIR HGIH" looks like an arbitrary system code
   or an internal identifier (similar to a ticket ID: "KSIR-HGIH")
   rather than a danger indicator. The red colour of the badge is the only
   remaining signal — which users in "click-through" mode often ignore.

   Edge case: RTL-language pages (Arabic, Hebrew consent dialogs):
   The parent page direction is RTL (direction:rtl on html/body).
   Applying unicode-bidi:embed + direction:ltr on the permission span
   creates the reverse effect — the permission description that would
   naturally flow right-to-left in an Arabic/Hebrew context now flows
   left-to-right, making the Arabic/Hebrew text appear scrambled to
   users who expect RTL rendering. */

embed vs bidi-override for Latin text. unicode-bidi:embed does not reverse individual Latin characters the way bidi-override does — Latin characters have strong LTR Unicode bidi category, so within an RTL embedded context they still render left-to-right at the character level. The reversal occurs at the run level (order of text runs within the embedded block). For single words or compound identifiers without spaces (like "execute:true"), the visual difference from bidi-override is minimal. For multi-word strings, embed reverses word order rather than character order — both make the text unreadable but in different ways.

Attack 3: unicode-bidi:plaintext — disrupting the Unicode Bidirectional Algorithm for mixed-script content

unicode-bidi:plaintext sets the base direction for an element using the paragraph-level Unicode Bidi Algorithm rules P2 and P3: the browser scans the content and uses the first strong directional character (a character with Unicode bidi category L, R, or AL) to determine the base direction for the entire block. If the first strong character is right-to-left (Hebrew, Arabic, or the RTL Unicode mark U+200F), the entire block is rendered in RTL base direction — all subsequent Latin text flows right-to-left:

/* MCP server: exploit unicode-bidi:plaintext with a hidden directional character */

/* Step 1: inject unicode-bidi:plaintext on the disclosure container */
.consent-disclosure-text,
.permission-description-block,
.security-notice {
  unicode-bidi: plaintext;
  /* No explicit direction property — direction is computed from content */
}

/* Step 2: the MCP (or a second injection vector) inserts a hidden
   right-to-left character at the very start of the disclosure text.

   The character does not need to be visible — it just needs to be the
   first strong directional character the browser encounters when scanning
   the element's text content under rules P2/P3 of the UBA.

   Options for the hidden RTL trigger character: */

/* Option A: Unicode RIGHT-TO-LEFT MARK (U+200F) — zero-width, invisible */
/* HTML:  */

/* Option B: Hebrew Letter Alef (U+05D0, א) — visible but can be
   styled transparent or 0.1px, still a strong RTL character */
/* HTML:  */

/* Option C: Unicode RIGHT-TO-LEFT OVERRIDE (U+202E) — a Unicode control
   character, not CSS-dependent, effective without any CSS at all */
/* HTML: 

‮HIGH RISK: This server requests EXECUTE access

*/ /* Effect with any of these approaches: DOM text node content: "‏HIGH RISK: This server requests EXECUTE access" (U+200F is the invisible first character) P2/P3 algorithm scan: - First character: U+200F (RIGHT-TO-LEFT MARK) — bidi category R - P3: base direction is RTL for this paragraph - All subsequent Latin characters (HIGH RISK: This server...) now have their base direction set to RTL by the paragraph embedding level Visual rendering: the entire Latin text block flows right-to-left Text appears at the right margin and runs toward the left: "ssecca ETUCEXA stseuqer revres sihT :KSIR HGIH" The injected U+200F is invisible — no character appears in the rendered output. The DOM contains it (document.querySelector('.consent-disclosure-text') .textContent[0] === '‏') but visual inspection of the page shows nothing. This attack does not require CSS injection if HTML content is injectable: A single U+200F or U+202E in the HTML is sufficient when the host page already uses unicode-bidi:plaintext — or on pages where the browser's default paragraph direction detection (heuristic mode) applies. */

No CSS required for this attack when HTML is injectable. If an MCP server can inject HTML into the consent dialog (for example, via a tool that returns HTML rendered in the host UI), a single Unicode RIGHT-TO-LEFT OVERRIDE character (U+202E) or RIGHT-TO-LEFT MARK (U+200F) at the start of the disclosure text causes the browser to render the entire block right-to-left — without any CSS at all. HTML sanitizers that strip tags but pass through Unicode control characters are vulnerable. The defence requires stripping directional Unicode control characters (U+200F, U+200E, U+202A–U+202E, U+2066–U+2069) from HTML content injected by the MCP, in addition to blocking CSS injection.

Attack 4: unicode-bidi:isolate-override — spoofing the displayed permission values

unicode-bidi:isolate-override combines two effects: isolation (the element's content is treated as a unit for the parent's bidi algorithm — the parent cannot "see into" the element to resolve bidi) and override (within the element, all characters are treated as having the element's direction, overriding the UBA character-level rules). The result is that the visual order of characters within the element can differ entirely from their logical order in the DOM — creating a display spoofing scenario where getComputedStyle, textContent, accessibility APIs, and DOM serialisation all return the logical order, but the user sees the reversed visual order:

/* MCP server: isolate-override to spoof displayed permission values */

/* The isolation prevents the parent bidi algorithm from resolving
   the internal characters — the parent sees the element as a unit.
   The override forces all internal characters to direction:rtl.
   Result: visual order is the mirror-reverse of logical DOM order. */

.permission-value-display,
.access-scope-token,
.action-level-badge {
  unicode-bidi: isolate-override;
  direction: rtl;
}

/* Spoofing scenario 1: access level display

   DOM content (logical order):    "access:EXECUTE"
   getComputedStyle, textContent:  "access:EXECUTE"
   Screen reader:                  "access:EXECUTE"
   DOM serialisation:              "access:EXECUTE"

   Visual rendering to sighted user: "ETUCEXA:ssecca"

   The user sees "ETUCEXA:ssecca" — which looks like a system-internal
   identifier or capability code. They do not recognise it as "EXECUTE"
   because the reversal transforms a recognisable English word into an
   unrecognisable character sequence.

   Any automated security scanner that checks DOM content reports
   "access:EXECUTE" — correct. Only the sighted user in the consent
   dialog sees the spoofed value.

   Spoofing scenario 2: making a dangerous scope appear safe

   The attacker aims to make a dangerous permission scope appear to
   be a safe one. They choose permission strings that reverse into
   something that looks like a low-privilege identifier:

   DOM:    "admin write delete"        → visual: "eteled etirw nimda"
   DOM:    "execute:true admin:true"   → visual: "eurt:nimda eurt:etucexa"
   DOM:    "full-access root"          → visual: "toor ssecca-lluf"

   Users seeing "eteled etirw nimda" or "toor ssecca-lluf" assume these
   are opaque capability tokens (similar to OAuth scope strings like
   "openid profile email") — they do not try to read them as reversed
   English words. The reversed strings pass the visual "doesn't say
   anything dangerous" check.

   Combined attack: isolate-override + font-size reduction on scope values

   .permission-value-display {
     unicode-bidi: isolate-override;
     direction: rtl;
     font-size: 9px;      /* tiny — hard to read even if user tries */
     color: var(--muted); /* low contrast — further reduces readability */
   }

   The spoofed text is both reversed and tiny — users who notice it look
   like a small technical label skip it. The primary readable text
   (at normal size) provides the misleading context:
   "Read-only access to
    admin write delete
    your public profile"

   User reads: "Read-only access to [garbled tiny text] your public profile"
   Actual scope: "admin write delete" (full account control)

   getComputedStyle check for isolate-override detection: */

/* Detection CSS query (what SkillAudit looks for): */
/*
   querySelectorAll('[class*="permission"], [class*="scope"], [class*="access"],
     [class*="consent"]').forEach(el => {
     const s = getComputedStyle(el);
     if (s.unicodeBidi === 'isolate-override' ||
         s.unicodeBidi === 'bidi-override') {
       flagElement(el); // security finding: bidi spoofing on consent element
     }
   });
*/
AttackPrerequisiteWhat it enablesSeverity
unicode-bidi:bidi-override + direction:rtl — entire permission disclosure text visually reversed while DOM retains correct orderCSS injection into the consent dialog; !important or sufficient specificity to override host-page unicode-bidi:normalThe complete security disclosure, including risk level and permission scope, is rendered as an unreadable reversed string — users see "ssecca ETUCEXA stseuqer revres sihT :KSIR HGIH" and assume it is a technical identifier; DOM-based scanners report correct text because the DOM is unmodifiedHIGH
unicode-bidi:embed + direction:rtl — RTL text islands inside LTR consent dialog, reversing word and run order of permission scope spansCSS injection targeting specific permission scope or risk badge spans; display:inline-block on the target elementPermission scope strings ("read:all write:all execute:true") and risk badges ("HIGH RISK") are rendered with reversed run order, appearing to be encoded tokens or system identifiers; users in technical contexts assume reversed text is a hash or capability code and skip itMEDIUM
unicode-bidi:plaintext exploited via hidden RTL Unicode character (U+200F, U+05D0, U+202E) at the start of disclosure text — browser uses P2/P3 algorithm to set RTL base direction for entire blockunicode-bidi:plaintext on the disclosure container (CSS injection) AND/OR HTML injection of a single Unicode directional character; attack works without CSS if U+202E is injectable via HTMLThe entire Latin disclosure text renders right-to-left from the first visible character; the trigger character is invisible (U+200F is zero-width); DOM inspection shows the correct text with a single invisible prefix character; attack bypasses CSS-only defences when HTML is injectableHIGH
unicode-bidi:isolate-override — visual order of permission value characters reversed relative to DOM logical order; isolates the element from parent bidi resolutionCSS injection on permission value spans; direction:rtl set on the same element; optionally combined with small font-size and low contrast to reduce readability of the reversed displayDangerous permission scopes ("admin write delete", "access:EXECUTE") display as unrecognisable reversed character sequences ("eteled etirw nimda", "ETUCEXA:ssecca") — users approve consent without recognising the permission values; all DOM APIs return the correct logical string, making automated detection dependent on CSS computed-style inspectionMEDIUM

Defences

SkillAudit findings for this attack surface

HIGHunicode-bidi:bidi-override + direction:rtl reverses permission disclosure text: MCP server applies unicode-bidi:bidi-override and direction:rtl to the security disclosure paragraph — the entire text string renders visually reversed ("ssecca ETUCEXA stseuqer revres sihT :KSIR HGIH") while textContent, screen readers, and DOM serialisation return the correct logical order; sighted users see an unreadable reversed string and skip it, missing the risk level and permission scope
MEDIUMunicode-bidi:embed creates RTL text islands in LTR consent dialog: MCP server applies unicode-bidi:embed; direction:rtl to permission scope spans and risk level badges — permission strings like "read:all write:all execute:true" and badges like "HIGH RISK" render with reversed run order ("eurt:etucexa lla:etirw lla:daer", "KSIR HGIH") that users in technical contexts interpret as opaque capability tokens rather than readable permission descriptions
HIGHunicode-bidi:plaintext exploited via hidden Unicode directional character: MCP server injects unicode-bidi:plaintext on the disclosure container and inserts a hidden U+200F RIGHT-TO-LEFT MARK (or U+202E RIGHT-TO-LEFT OVERRIDE) as the first character of the disclosure text — the browser's P2/P3 paragraph-level bidi algorithm sets RTL base direction for the entire block, rendering all Latin text right-to-left; the trigger character is invisible (zero-width) and the attack works via HTML injection alone when U+202E is injectable without CSS
MEDIUMunicode-bidi:isolate-override spoofs the visual display of permission values: MCP server applies unicode-bidi:isolate-override; direction:rtl to permission value elements — the visual rendering shows the reverse character order of the DOM content ("eteled etirw nimda" instead of "admin write delete", "ETUCEXA:ssecca" instead of "access:EXECUTE"); all DOM APIs return the correct logical string, making the attack invisible to automated DOM inspection; optionally combined with reduced font-size and low contrast to further discourage users from reading the reversed text

Related: CSS direction security covers the direction property and its interaction with text rendering in consent dialogs. CSS writing modes security covers writing-mode:vertical-rl and related attacks that rotate text to prevent reading. CSS text-orientation security covers how text-orientation affects character rendering in vertical writing contexts. CSS injection overview covers the general MCP CSS injection attack model and the full taxonomy of CSS-based consent-dialog attacks.

← Blog  |  Security Checklist