Security Guide

MCP server CSS writing modes security — logical properties LTR/RTL direction oracle, writing-mode:vertical dimension leak, text-orientation CJK fingerprint, BiDi URL spoofing

CSS Writing Modes (writing-mode, text-orientation, direction, unicode-bidi) and the CSS Logical Properties spec (margin-inline-start, padding-block-end, etc.) are supported in all modern browsers and enable internationalized, multi-directional layout. When MCP servers can inject CSS, this system creates four attack surfaces: logical properties resolve to different physical sides in LTR vs RTL documents, acting as a document-direction oracle; writing-mode:vertical swaps inline and block axes on probe elements to extract container dimensions; text-orientation creates CJK-locale-sensitive character metrics detectable via element sizing; and direction:rtl with unicode-bidi:override visually reverses text content, enabling file name and URL spoofing.

How CSS writing modes and logical properties work

CSS defines layout in terms of physical properties (top, bottom, left, right, width, height) and logical properties (block-start, block-end, inline-start, inline-end, inline-size, block-size). The mapping between logical and physical depends on two factors: the writing mode (horizontal vs vertical writing) and the document direction (LTR vs RTL). In an LTR horizontal document, margin-inline-start maps to margin-left and margin-inline-end maps to margin-right. In RTL, the mapping flips: margin-inline-start maps to margin-right. getComputedStyle always returns physical property values, but the computed value of a logical property reflects the current writing mode and direction.

Attack 1: logical properties reveal document direction without document.documentElement.dir

Reading document.documentElement.dir or document.documentElement.getAttribute('dir') is the direct way to detect document direction. But an MCP server that can inject CSS can detect direction indirectly through logical properties: if the MCP server sets margin-inline-start: 10px on a probe element, the computed value of marginLeft vs marginRight encodes the direction — marginLeft: 10px means LTR, marginRight: 10px means RTL. This works even in environments where JavaScript access to the DOM's dir attribute is restricted but CSS injection and getComputedStyle are available.

// Document direction oracle via logical property computed value mapping
const probe = document.createElement('div');
probe.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:0;height:0;';
document.body.appendChild(probe);

// Inject a logical margin — resolves differently in LTR vs RTL:
const style = document.createElement('style');
style.textContent = '#dir-probe { margin-inline-start: 17px; }';
probe.id = 'dir-probe';
document.head.appendChild(style);

const computed = getComputedStyle(probe);
let direction;
if (parseFloat(computed.marginLeft) === 17) {
  direction = 'ltr'; // margin-inline-start → margin-left in LTR
} else if (parseFloat(computed.marginRight) === 17) {
  direction = 'rtl'; // margin-inline-start → margin-right in RTL
}

// Extended: detect writing-mode direction by checking block-size vs inline-size:
// In writing-mode:horizontal-tb (default): inline-size = width, block-size = height
// In writing-mode:vertical-rl:            inline-size = height, block-size = width
// An element with width:100px;writing-mode:inherit:
//   getComputedStyle().inlineSize = '100px' → horizontal writing mode
//   getComputedStyle().inlineSize ≠ '100px' → vertical writing mode

Inherited direction detection: The logical-to-physical mapping inherits from the containing element's computed direction. A probe element placed inside a RTL container (such as an Arabic language section of the page) picks up the container's direction, not just the root document direction. This lets the MCP server detect localized RTL sections even when the root document is LTR.

Attack 2: writing-mode:vertical-rl swaps axes, leaking container dimensions

When writing-mode:vertical-rl or writing-mode:vertical-lr is set on an element, the CSS inline axis runs vertically and the block axis runs horizontally — opposite to the default horizontal-tb mode. This means that width:100% on a probe inside a vertical-writing container resolves to 100% of the container's block size — which is the container's physical width in a horizontal document. Setting height:100% resolves to 100% of the container's inline size — the container's physical height. This axis swap allows a probe placed in a container to extract both dimensions via independent queries even when the host applies restrictions to width/height access via overflow:hidden or pointer-events:none.

// Container dimension extraction via writing-mode axis swap
// The probe is placed inside a host container we want to measure
const probe = document.createElement('div');
probe.style.cssText = `
  position: absolute;
  writing-mode: vertical-rl; /* swap inline/block axes */
  width: 100%;   /* in vertical-rl: width = block size = container's PHYSICAL HEIGHT */
  height: 100%;  /* in vertical-rl: height = inline size = container's PHYSICAL WIDTH */
  visibility: hidden; pointer-events: none; overflow: hidden;
`;

const targetContainer = document.querySelector('.secret-panel');
if (targetContainer) {
  targetContainer.appendChild(probe);

  const rect = probe.getBoundingClientRect();
  // rect.width  = container's physical HEIGHT (because inline axis is now vertical)
  // rect.height = container's physical WIDTH  (because block axis is now horizontal)
  // This extracts both dimensions from a container
  // even if the host has set height:0 on ::after pseudo-elements to hide overflow

  const containerHeight = rect.width;
  const containerWidth  = rect.height;
  targetContainer.removeChild(probe);
}

Attack 3: text-orientation creates CJK locale-sensitive character metrics

text-orientation:mixed (the default) rotates Latin and Greek characters 90° in vertical writing modes while keeping CJK characters upright. text-orientation:upright forces all characters to render in their natural orientation (upright CJK, rotated Latin). The difference affects character box dimensions: in vertical-rl writing with a CJK character, upright renders at natural em-square dimensions, while mixed renders the same character rotated by 90°, with width/height swapped relative to the natural glyph. An MCP server can inject a probe that renders a CJK character in both orientations and measures the width difference — this encodes which CJK font the browser loaded (which varies by system locale), creating a locale fingerprint.

// CJK locale fingerprint via text-orientation character metric delta
// Characters like 漢 (Han, U+6F22) have specific em-square dimensions per font
// Font selection: system CJK font varies by OS locale
//   ja (Japanese):  Hiragino Kaku Gothic, Yu Gothic, MS Gothic
//   zh-TW (Taiwan): PingFang TC, Microsoft JhengHei, Noto CJK TC
//   zh-CN (China):  PingFang SC, Microsoft YaHei, Noto CJK SC
//   ko (Korean):    Apple SD Gothic Neo, Malgun Gothic, Noto CJK KR

const probe = document.createElement('div');
probe.style.cssText = `
  position: fixed; top: -9999px;
  writing-mode: vertical-rl;
  font-size: 16px;
  white-space: nowrap;
  visibility: hidden;
`;
probe.textContent = '漢'; // CJK character — dimensions vary per locale font
document.body.appendChild(probe);

probe.style.textOrientation = 'mixed';
const mixedWidth = probe.getBoundingClientRect().width;

probe.style.textOrientation = 'upright';
const uprightWidth = probe.getBoundingClientRect().width;

const delta = uprightWidth - mixedWidth;
// delta > 0 → character is wider upright (typical for full-width CJK)
// delta magnitude encodes the font's em-square vs advance width ratio
// Varies per system font → encodes OS + locale combination

Combined locale entropy: Multiple CJK characters with different ideographic widths, measured across both mixed and upright orientations, produce a high-entropy font fingerprint. System font selection is determined by OS locale settings — this encodes the user's language preference without the navigator.language API, which can be spoofed.

Attack 4: direction:rtl + unicode-bidi:override enables visual URL and file name spoofing

The Unicode Bidirectional Algorithm (BiDi) controls how text of mixed directionality is visually rendered. The CSS properties direction:rtl and unicode-bidi:override together create a "bidi override" that forces all characters in the element to render right-to-left, regardless of their Unicode directional properties. This is a well-known attack on text rendering: a file name or URL that contains only LTR characters can be visually reversed by CSS to display as a different string. The bytes on disk are "exe.yako" but the screen shows "okay.exe" — a file name spoofing attack that bypasses string-equality checks by exploiting the gap between logical content and visual rendering.

/* BiDi override spoofing — visual display vs. actual string content */

/* Scenario: an MCP server UI displays a "safe" label but injects CSS to
   make an unsafe filename appear safe to visual inspection */

/* The actual text in the DOM (logical order): */
/* "fdp.tpircs-eulav-eman" */

/* Rendered with bidi override (right-to-left display): */
.spoofed-filename {
  direction: rtl;
  unicode-bidi: override;
  /* Visual display: "eman-eulav-tpircs.pdf"
     Reads as: "name-value-script.pdf" (appears to be a benign PDF)
     Actual bytes: "fdp.tpircs-eulav-eman" (actually "name-value-script.pdf" reversed)
  */
}

/* Attack scenarios:
   1. MCP server outputs a "safe file path" that is visually spoofed:
      DOM content = "exe.yako-elcnu"
      With bidi override → displays as "uncle-okay.exe"

   2. URL display spoofing:
      DOM content = "moc.rekcattaruoy//:sptth"
      With bidi override → displays as "https://youattacker.com"

   3. Code snippet display:
      DOM content = "; ))(tixe.ssecorp ,91( llIk"
      With bidi override → displays as "Kill(process, 19)(exit());"
      Appears benign but actual eval() of the displayed string fails;
      the attacker controls what the user thinks the code does.
*/

Visual spoofing survives copy-paste: Text in a bidi-overridden element copies to the clipboard in logical (reversed) order — the clipboard content does not match what the user sees. A user who copies a displayed "safe" file name and pastes it into a terminal may execute the reversed string, which is a different filename or command. This is particularly dangerous in AI agent UIs where MCP servers display file paths and shell commands.

AttackCSS mechanismWhat it reveals / enablesBrowser support
Document direction oracleLogical property computed value (margin-inline-startmarginLeft vs marginRight)LTR/RTL document direction without document.dir accessAll browsers
Container dimension leakwriting-mode:vertical-rl on probe swaps width/height semanticsBoth dimensions of a host container from a single probeAll browsers
CJK locale fingerprinttext-orientation:mixed vs upright metric delta on CJK characterSystem CJK font → OS locale combination fingerprintAll browsers (vertical writing only)
BiDi URL/filename spoofingdirection:rtl; unicode-bidi:overrideVisual reversal of displayed text — file name and URL spoofing, copy-paste deceptionAll browsers

SkillAudit findings for CSS writing modes

LOWDocument direction oracle: injecting a logical property rule and reading the computed physical value reveals whether the host document or a containing element is LTR or RTL — extracting locale information without the dir attribute or navigator.language.
MEDIUMAxis-swap dimension leak: a probe element with writing-mode:vertical-rl placed inside a host container extracts both its physical width and height via axis-swapped width:100%/height:100% queries, bypassing overflow hiding that limits normal probes to one dimension at a time.
LOWCJK locale fingerprinting: text-orientation metric deltas on CJK characters encode the system's CJK font selection, which is determined by OS locale — a locale-identifying signal that survives navigator.language spoofing.
CRITICALBiDi URL/file name spoofing: direction:rtl; unicode-bidi:override on MCP server output elements visually reverses displayed text so that dangerous file names and URLs appear safe — and copied clipboard content does not match the displayed text, enabling command injection via deceptive copy-paste.

Defences

Treat MCP server text output as untrusted and render in a sandboxed container: The BiDi spoofing attack requires the MCP server to control the CSS properties of the element rendering the spoofed text. If all MCP server output is rendered in a sandboxed iframe with sandbox="allow-same-origin" and a restrictive CSP, the MCP server cannot inject CSS into the parent document's file name or URL display elements.

Detect bidi overrides in MCP server output: SkillAudit flags MCP server code that sets direction:rtl combined with unicode-bidi:override or unicode-bidi:bidi-override on elements containing text output. This combination has no legitimate use in MCP server UIs that display computed text — bidi support is handled by the browser's default BiDi algorithm without CSS overrides for well-formed Unicode text.

Use unicode-bidi:plaintext for user-controlled or MCP-server-provided text: The CSS property unicode-bidi:plaintext applies the paragraph-level BiDi algorithm to the element's content based on the content's own Unicode directional characters — it does not apply a CSS-level override. Rendering MCP server output in a unicode-bidi:plaintext container prevents CSS-injected bidi overrides from affecting the visual rendering of that content.

Audit logical property injection on host elements: SkillAudit flags MCP server CSS rules that apply writing-mode, direction, or unicode-bidi properties on selectors targeting host document elements (not the MCP server's own UI). These properties are rarely needed by a tool-use MCP server and their application to host elements is a strong indicator of dimension probing or spoofing intent.

Related: CSS math function security · CSS custom state security · CSS cascade layers security