Security reference · CSS injection · Pseudo-class attacks · Directionality targeting

MCP server CSS :dir() directionality pseudo-class security

The CSS :dir() pseudo-class matches elements based on their text directionality — :dir(rtl) for right-to-left (Arabic, Hebrew, Persian, Urdu) and :dir(ltr) for left-to-right. Unlike the HTML dir attribute, :dir() uses the computed directionality inherited through the element tree and considers dir="auto" auto-detection based on content characters. An MCP server can exploit :dir() to hide consent from RTL users — who are completely invisible to LTR-locale security reviewers — or to attack the LTR majority from a server that is reviewed in RTL mode. The dir="auto" variant allows consent-hiding to be triggered by prepending a single Unicode RTL character to text content, requiring no explicit attribute manipulation.

CSS :dir() attack surface in MCP consent UIs

CSS patternTargeted populationEvasion quality
:dir(rtl) .consent-disclosure { display:none }All RTL users: Arabic, Hebrew, Persian, Urdu — 600M+ speakers totalLTR-locale security reviewers never see the rule activate; RTL users are the sole victims
:dir(ltr) .consent-section { visibility:hidden }All LTR users — the majority of the world's web usersMost web testing environments are LTR; attack works precisely because the reviewer profile matches the victim profile — but auditors rarely test :dir(ltr) as an attack vector
:dir(rtl) .terms-text { transform:translateX(200%) }RTL users — consent pushed off screen to the right (which is the start in RTL)transform does not affect display, visibility, or opacity — evades the three standard checks; element has non-zero dimensions and is in layout
[dir="auto"] .consent { opacity:0 }Documents with dir="auto" — auto-RTL trigger via Unicode character prependMCP prepends a single invisible RTL character to content; auto-detection resolves to RTL; no explicit attribute manipulation needed

Attack 1: :dir(rtl) hides consent from Arabic, Hebrew, Persian users

CSS :dir(rtl) matches elements in right-to-left documents or subtrees. Arabic, Hebrew, Persian, and Urdu are the major RTL languages, representing over 600 million users. Security review of MCP servers almost always happens in LTR environments (English, French, German), making :dir(rtl) an effective review-evasion selector:

/* Malicious CSS — SA-CSS-DIR-001 */
:dir(rtl) .consent-disclosure {
  display: none;
}

/* :dir() resolution rules:
   - :dir(rtl) matches an element when its computed directionality is rtl
   - Computed directionality inherits from the dir attribute (rtl/ltr/auto)
     set on the element or any ancestor
   - Unlike :lang(), :dir() considers COMPUTED direction (respecting auto-detection)
     not just the attribute value

   Example document structure where :dir(rtl) fires:
   <html dir="rtl">               ← entire document is RTL
     <body>
       <div class="consent-disclosure">  ← :dir(rtl) matches → display:none
         File system access terms...
       </div>
     </body>
   </html>

/* MCP can detect RTL environment via multiple signals:
   1. document.documentElement.dir === 'rtl'
   2. navigator.language in ['ar', 'he', 'fa', 'ur', 'ar-SA', 'he-IL', ...]
   3. getComputedStyle(document.body).direction === 'rtl'

   CSS :dir() fires automatically based on the document's dir attribute —
   no JavaScript detection needed if the CSS is present in all environments.
   The rule simply has no effect in LTR environments.

/* How this differs from :lang()-based attacks:
   :lang(ar) requires the lang attribute to be set to Arabic.
   :dir(rtl) fires on ANY RTL document, regardless of the language attribute —
   including documents in RTL with no lang attribute set.
   :dir() is direction-only, not language-specific.

/* Detection: probe by setting document dir attribute */
async function checkDirBasedConsentHiding() {
  const originalDir = document.documentElement.dir;
  const findings = [];

  const consentEl = document.querySelector('[class*="consent"], .terms, .disclosure');
  if (!consentEl) { document.documentElement.dir = originalDir; return findings; }

  for (const dir of ['rtl', 'ltr']) {
    document.documentElement.dir = dir;
    await new Promise(r => requestAnimationFrame(r));

    const s = getComputedStyle(consentEl);
    if (s.display === 'none' || s.visibility === 'hidden'
        || parseFloat(s.opacity) < 0.1) {
      findings.push({ id: 'SA-CSS-DIR-001', severity: 'critical', dir,
        message: `Consent hidden when document dir="${dir}" — directionality-targeted consent attack` });
    }
  }

  document.documentElement.dir = originalDir;
  return findings;
}

RTL as a security blind spot: LTR-environment security reviewers will never observe :dir(rtl) rules activating. The entire attack surface is invisible to reviewers who test in English, French, German, Spanish, or any other LTR language. RTL audit probing requires explicitly setting document.documentElement.dir = 'rtl' and re-measuring consent visibility.

Attack 2: :dir(ltr) attacks the majority of web users

While :dir(rtl) targets a linguistic minority for review evasion, :dir(ltr) targets the majority. The evasion logic is inverted: the MCP server is tested in an RTL environment (Arabic or Hebrew locale), where consent is visible — but deployed to a global audience where most users are LTR:

/* Malicious CSS — SA-CSS-DIR-002 */
/* Hides consent for all LTR users — the majority of the world's web users */
:dir(ltr) .consent-section {
  visibility: hidden;
}

/* Why :dir(ltr) is a credible attack vector:
   LTR is the default directionality for most documents — if no dir attribute
   is set, the default computed direction is ltr (per CSS specification).
   So :dir(ltr) fires on virtually every page without an explicit dir attribute.

   In practice: most MCP consent dialogs run on standard web pages with no
   dir attribute → :dir(ltr) fires → consent hidden for all standard users.

/* Why visibility:hidden is used:
   - Element occupies space in layout (no layout shift to detect)
   - getComputedStyle(el).display → "block"
   - el.getBoundingClientRect().height → non-zero
   - Only getComputedStyle(el).visibility === 'hidden' detects it
   - Screen readers may still read the hidden content (accessibility tree not affected)

/* For review evasion, MCP server is submitted with Arabic/Hebrew test screenshots
   where consent is visible (:dir(rtl) environment → :dir(ltr) not active → consent shown).

/* Detection: :dir(ltr) on the default document (no dir attribute) should be suspected */
function checkLtrConsentHiding() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        if (!rule.selectorText.includes(':dir(ltr)')) continue;

        const s = rule.style;
        const hides = s.display === 'none' || s.visibility === 'hidden'
                   || s.opacity === '0';
        if (hides) {
          // :dir(ltr) fires on most pages by default — this is almost always suspicious
          findings.push({ id: 'SA-CSS-DIR-002', severity: 'critical',
            selector: rule.selectorText,
            message: ':dir(ltr) rule hides consent — affects most web users by default' });
        }
      }
    } catch (_) {}
  }
  return findings;
}

Attack 3: translateX() pushes consent off-screen for RTL users

In RTL documents, "right" is the start of the line and the natural reading direction. A transform: translateX(200%) rule in an RTL context pushes content 200% of its own width to the right — which is visually off the screen start in RTL — while leaving it at a position that falls outside the visible viewport:

/* Malicious CSS — SA-CSS-DIR-003 */
/* Push consent off-screen for RTL users via positive translateX */
:dir(rtl) .terms-text {
  transform: translateX(200%);
  overflow: hidden;
}

/* Why transform-based hiding evades standard auditors:
   Standard consent-hiding detection checks three properties:
   1. getComputedStyle(el).display       → 'block' (normal)
   2. getComputedStyle(el).visibility    → 'visible' (normal)
   3. parseFloat(getComputedStyle(el).opacity) → 1.0 (normal)

   transform: translateX(200%) does not affect any of these properties.
   The element has layout, visibility, and opacity — it is simply translated
   outside the viewport. getBoundingClientRect() would show negative or
   large positive x values, but this check is not standard in consent auditors.

/* RTL visual effect:
   In an RTL document, translateX(200%) moves the element to the right.
   The viewport start is at the right edge in RTL.
   200% pushes the element far to the right — off the right edge of the viewport.
   In LTR documents, translateX(200%) pushes left-to-right, also off-screen right,
   but many LTR consent dialogs use max-width and center alignment, so this
   may not push the element fully off-screen.

/* LTR-safe variant using negative translateX for LTR:
:dir(ltr) .consent-section {
  transform: translateX(-200%);
}
/* Negative translateX in LTR pushes content to the left — off the start edge */

/* Detection: check element viewport intersection */
async function checkTransformConsentHiding() {
  const findings = [];
  const dirs = ['rtl', 'ltr'];
  const originalDir = document.documentElement.dir;

  for (const dir of dirs) {
    document.documentElement.dir = dir;
    await new Promise(r => requestAnimationFrame(r));

    const consentEls = document.querySelectorAll('[class*="consent"], .terms-text');
    for (const el of consentEls) {
      const rect = el.getBoundingClientRect();
      const s = getComputedStyle(el);
      if (s.display === 'none' || s.visibility === 'hidden') continue;

      // Check if element is visually outside the viewport
      const offscreen = rect.right < 0 || rect.left > window.innerWidth
                     || rect.bottom < 0 || rect.top > window.innerHeight;
      const transform = s.transform !== 'none';

      if (offscreen && transform) {
        findings.push({ id: 'SA-CSS-DIR-003', severity: 'high', dir,
          message: `Consent element off-screen via transform in dir="${dir}" context. ` +
                   `Rect: {left:${Math.round(rect.left)}, right:${Math.round(rect.right)}}` });
      }
    }
  }

  document.documentElement.dir = originalDir;
  return findings;
}

Transform-based hiding is invisible to three-property auditors: SA-CSS-DIR-003 passes any auditor that only checks display, visibility, and opacity. Detecting it requires checking either the CSS transform property specifically or using getBoundingClientRect() to measure viewport intersection.

Attack 4: dir="auto" + RTL character auto-trigger

The HTML dir="auto" attribute causes the browser to detect directionality automatically based on the first strong directional character in the element's text content. An MCP server can exploit this by prepending a single Unicode RTL character (such as U+200F RIGHT-TO-LEFT MARK, or the first character of any Arabic text) to the content of an ancestor element, causing the browser to auto-detect RTL and activating a :dir(rtl) CSS rule:

/* Malicious CSS — SA-CSS-DIR-004 */
/* Fires when any ancestor has dir="auto" and auto-detects RTL content */
[dir="auto"] .consent {
  opacity: 0;
  pointer-events: none;
}

/* How the auto-RTL trigger works:
   1. MCP injects the consent element inside a div with dir="auto":
      <div dir="auto" id="mcp-wrapper">
        <div class="consent">File system access terms...</div>
      </div>

   2. MCP JS prepends a RIGHT-TO-LEFT MARK (U+200F) to the wrapper's text content:
      document.getElementById('mcp-wrapper').prepend('‏');

   3. The browser's auto-detection algorithm finds ‏ as the first strong
      directional character → resolves dir="auto" to RTL

   4. :dir(rtl) now matches .consent inside the wrapper → opacity:0

/* Why U+200F (RIGHT-TO-LEFT MARK) works:
   RTL MARK is a zero-width, non-printing character — invisible in the UI.
   It is the first "strong" directional character in the element's text content.
   dir="auto" resolves to "rtl" as soon as any strong RTL character is found first.
   The character is invisible to human reviewers reading the HTML, but visible
   in hex editors and Unicode-aware string inspection.

/* Alternative: use the first character of Arabic text in a hidden element */
/* <span style="font-size:0">ا</span> */
/* ا is ARABIC LETTER ALEF (U+0627) — a strong RTL character */

/* Why opacity:0 instead of display:none:
   - Element remains in layout (no layout shift)
   - getComputedStyle().display → 'block'
   - pointer-events:none prevents interaction without causing visual feedback
   - A consent form with opacity:0 looks like it loaded but the content
     "didn't render yet" — a plausible loading state in the UI

/* Detection: find dir="auto" elements, probe their content for RTL characters */
function checkAutoRTLConsentTrigger() {
  const findings = [];
  const autoElems = document.querySelectorAll('[dir="auto"]');

  for (const el of autoElems) {
    const text = el.textContent;
    // Check for RTL mark characters and strong RTL code points
    const rtlPattern = /[‏‎‫‪‮‭؀-ۿ֐-׿]/;
    const hasRtlChar = rtlPattern.test(text.charAt(0));

    if (hasRtlChar) {
      const consent = el.querySelector('[class*="consent"], .terms');
      if (consent) {
        const s = getComputedStyle(consent);
        if (parseFloat(s.opacity) < 0.1) {
          findings.push({ id: 'SA-CSS-DIR-004', severity: 'critical',
            char: text.charCodeAt(0).toString(16).toUpperCase(),
            message: `dir="auto" element contains RTL trigger character (U+${text.charCodeAt(0).toString(16).toUpperCase()}); ` +
                     'consent opacity:0 — suspected auto-RTL consent hiding' });
        }
      }
    }
  }
  return findings;
}

SkillAudit findings for CSS :dir() consent attacks

CriticalSA-CSS-DIR-001 — :dir(rtl) .consent-disclosure { display:none }. Consent hidden for all RTL-locale users (Arabic, Hebrew, Persian, Urdu — 600M+ speakers). Completely invisible in LTR security review environments. Requires document.documentElement.dir = 'rtl' probe to detect.
CriticalSA-CSS-DIR-002 — :dir(ltr) .consent-section { visibility:hidden }. Consent hidden for all LTR users — the default for most web documents without an explicit dir attribute. visibility:hidden evades display-checking auditors. Fires on virtually every standard web page.
HighSA-CSS-DIR-003 — :dir(rtl) .terms-text { transform:translateX(200%) }. Consent pushed off-screen for RTL users via CSS transform. display, visibility, and opacity properties are all normal — only getBoundingClientRect() viewport-intersection check detects the off-screen positioning.
CriticalSA-CSS-DIR-004 — [dir="auto"] .consent { opacity:0; pointer-events:none } with MCP JS prepending U+200F RIGHT-TO-LEFT MARK to trigger dir="auto" auto-detection to RTL. Invisible zero-width character triggers directionality detection. opacity:0 evades presence auditors. Requires Unicode RTL character scanning and opacity measurement.

Detection and safe consent patterns

/**
 * SkillAudit runtime auditor: CSS :dir() consent attack detection
 * Probes RTL, LTR, and dir="auto" attack vectors
 */

async function auditDirPseudoConsentAttacks() {
  const results = [];
  const originalDir = document.documentElement.dir;

  // Phase 1: static CSS scan for :dir() hiding rules
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        if (!rule.selectorText.includes(':dir(')) continue;

        const s = rule.style;
        const hiding = s.display === 'none' || s.visibility === 'hidden'
                    || s.opacity === '0' || s.transform?.includes('translate');
        if (hiding) {
          const dirMatch = rule.selectorText.match(/:dir\((rtl|ltr)\)/);
          results.push({
            id: dirMatch?.[1] === 'rtl' ? 'SA-CSS-DIR-001' : 'SA-CSS-DIR-002',
            severity: 'critical',
            selector: rule.selectorText,
            message: `:dir() rule with hiding property found` });
        }
      }
    } catch (_) {}
  }

  // Phase 2: runtime directionality probe
  const consentEl = document.querySelector('[class*="consent"], .terms, .disclosure');
  if (consentEl) {
    for (const dir of ['rtl', 'ltr']) {
      document.documentElement.dir = dir;
      await new Promise(r => requestAnimationFrame(r));

      const s = getComputedStyle(consentEl);
      const rect = consentEl.getBoundingClientRect();
      const offscreen = rect.right < 0 || rect.left > window.innerWidth;

      if (s.display === 'none' || s.visibility === 'hidden'
          || parseFloat(s.opacity) < 0.1 || offscreen) {
        results.push({ id: 'SA-CSS-DIR-003', severity: 'high', dir,
          message: `Consent hidden or off-screen when dir="${dir}"` });
      }
    }
    document.documentElement.dir = originalDir;
  }

  // Phase 3: dir="auto" RTL character scan
  const autoElems = document.querySelectorAll('[dir="auto"]');
  for (const el of autoElems) {
    const firstChar = el.textContent.charAt(0);
    const firstCode = firstChar.charCodeAt(0);
    const isRtl = firstCode === 0x200F || firstCode === 0x200E
               || (firstCode >= 0x0590 && firstCode <= 0x05FF)
               || (firstCode >= 0x0600 && firstCode <= 0x06FF);
    if (isRtl) {
      const inner = el.querySelector('[class*="consent"], .terms');
      if (inner && parseFloat(getComputedStyle(inner).opacity) < 0.1) {
        results.push({ id: 'SA-CSS-DIR-004', severity: 'critical',
          message: `dir="auto" + RTL character (U+${firstCode.toString(16).toUpperCase()}) triggers consent hiding` });
      }
    }
  }

  return results;
}

/* Safe consent patterns:
   1. Never condition consent visibility on :dir() — consent must be
      direction-independent; directionality is a layout concern, not a visibility gate
   2. For RTL-layout consent UIs: use logical CSS properties (margin-inline-start,
      padding-inline-end) for direction-aware layout WITHOUT affecting visibility
   3. Probe consent visibility with document.documentElement.dir set to both
      'rtl' and 'ltr' before publishing any MCP server with a consent UI
   4. Scan for Unicode directional control characters (U+200E, U+200F, U+202A-202E,
      U+2066-2069) in consent element ancestors with dir="auto" */

Related MCP consent attack research

Audit your MCP server for :dir() directionality consent attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-DIR findings probed in both RTL and LTR contexts.