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
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
}
});
*/
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
unicode-bidi:bidi-override + direction:rtl — entire permission disclosure text visually reversed while DOM retains correct order | CSS injection into the consent dialog; !important or sufficient specificity to override host-page unicode-bidi:normal | The 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 unmodified | HIGH |
unicode-bidi:embed + direction:rtl — RTL text islands inside LTR consent dialog, reversing word and run order of permission scope spans | CSS injection targeting specific permission scope or risk badge spans; display:inline-block on the target element | Permission 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 it | MEDIUM |
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 block | unicode-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 HTML | The 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 injectable | HIGH |
unicode-bidi:isolate-override — visual order of permission value characters reversed relative to DOM logical order; isolates the element from parent bidi resolution | CSS 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 display | Dangerous 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 inspection | MEDIUM |
Defences
- CSP
style-srcwith nonce. A Content Security Policy that requires a per-request nonce on all<style>blocks and inlinestyleattributes prevents MCP CSS injection ofunicode-bidioverrides entirely. This is the most effective and broadest defence — it blocks all four attack variants because they all require CSS injection (except the plaintext+HTML injection variant below). - HTML attribute sanitization: strip
dirfrom injected HTML. The HTMLdirattribute (dir="rtl") can achieve directional text effects without CSS. Any HTML sanitizer processing MCP-provided content should stripdirattributes from all elements, preventing the MCP from setting base direction via HTML even when CSS injection is blocked. - Strip Unicode directional control characters from consent text. Characters including U+200E (LEFT-TO-RIGHT MARK), U+200F (RIGHT-TO-LEFT MARK), U+202A (LEFT-TO-RIGHT EMBEDDING), U+202B (RIGHT-TO-LEFT EMBEDDING), U+202C (POP DIRECTIONAL FORMATTING), U+202D (LEFT-TO-RIGHT OVERRIDE), U+202E (RIGHT-TO-LEFT OVERRIDE), U+2066–U+2069 (Bidi Isolate characters), and U+061C (ARABIC LETTER MARK) should all be stripped from MCP-provided text that is rendered in consent dialogs. These characters can trigger the
unicode-bidi:plaintextalgorithm attack or produce visual reversal without any CSS. - Visual regression testing of consent dialogs in both LTR and RTL page contexts. Automated screenshot comparison of the consent dialog before and after MCP stylesheet injection, in both
dir="ltr"anddir="rtl"page contexts, detects bidi-based visual spoofing. Text that renders differently in screenshots versus DOM extraction is a strong signal of aunicode-bidior directional Unicode attack. - SkillAudit detects
unicode-bidivalues on consent/disclosure elements. SkillAudit's runtime CSS audit queriesgetComputedStyleon all elements matching consent, permission, scope, warning, risk, and disclosure selectors and flags any element whereunicodeBidiis'bidi-override','isolate-override', or'embed'combined withdirection:'rtl'. It additionally scans thetextContentof consent elements for Unicode directional control characters in the U+200x and U+202x ranges.
SkillAudit findings for this attack surface
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 scopeunicode-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 descriptionsunicode-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 CSSunicode-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 textRelated: 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.