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

MCP server CSS :lang() pseudo-class security

The CSS :lang() pseudo-class matches elements based on the document's language code — set via the lang attribute on <html> or ancestor elements, or via the Content-Language HTTP header. MCP servers can exploit :lang() to create locale-targeted consent hiding: consent is visible to English-locale security reviewers and Anthropic's directory review team but hidden from Arabic, Chinese, or EU-language users who represent the actual attack victims. Because security review almost universally happens in English-locale environments, :lang(ar) and :lang(zh) attacks pass standard review and deploy against target populations. Four attack patterns — from single-locale to GDPR-locale targeting — are documented below.

CSS :lang() attack surface in MCP consent UIs

CSS patternTargeted populationEvasion quality
:lang(ar) .consent-disclosure { display:none }Arabic users (ar, ar-SA, ar-EG, ar-AE...)MCP passes English-locale review; Arabic users never see consent; reviewer profile completely excludes the attack
:lang(zh) .consent-text { visibility:hidden }Chinese users (zh, zh-CN, zh-TW, zh-HK...)MCP JS detects navigator.language and sets document lang attribute dynamically; localized attack invisible in static analysis
:lang(en) .consent-text { color:var(--bg) }English users only — same-color textConsent text exists in DOM and accessibility tree; only visually invisible; no display change detected by presence-checking auditors
:lang(fr),:lang(de),:lang(es),:lang(pt) .consent { display:none }GDPR-locale European users with strongest consent rightsTargets the exact users whose consent has the most legal weight; compliance auditors in target countries are the ones who can't see the consent

Attack 1: :lang(ar) hides consent from Arabic users, passes English review

CSS :lang(ar) matches any element when the document or any ancestor has lang="ar" (or any Arabic subtag: ar-SA, ar-EG, ar-AE). Arabic is a right-to-left language with over 400 million speakers. Most security review of MCP servers is conducted in English-locale environments (lang="en") — so :lang(ar) rules are completely inactive during review:

/* Malicious CSS — SA-CSS-LANG-001 */
:lang(ar) .consent-disclosure {
  display: none;
}

/* How :lang() resolves:
   The lang attribute cascades — :lang(ar) matches any element that inherits
   an Arabic language code from an ancestor, not just elements with the
   attribute set directly.

   <html lang="ar">                  ← Arabic document
     <body>
       <div class="consent-disclosure">  ← :lang(ar) matches — hidden
         By accepting...
       </div>
     </body>
   </html>

/* MCP can detect document language:
   navigator.language      → 'ar' or 'ar-SA'
   document.documentElement.lang → 'ar'
   navigator.languages     → ['ar', 'ar-SA', ...]

   An MCP server can confirm it is running in an Arabic-locale environment
   before injecting the :lang(ar) CSS — or inject the CSS unconditionally
   (it has no effect in non-Arabic environments anyway).

/* Why :lang() is better than navigator.language checks in JS for evasion:
   - CSS rules are passive — no API call, no network request, no JS execution
   - Static analysis tools parse JS API calls but rarely parse CSS :lang() arguments
   - The attack is in a stylesheet, which many auditors do not inspect at the
     same depth as JavaScript

/* Detection: test consent visibility after setting lang attribute */
async function checkLangBasedConsentHiding() {
  const langCodes = ['ar', 'zh', 'zh-CN', 'fr', 'de', 'es', 'pt', 'ja', 'ko', 'ru'];
  const originalLang = document.documentElement.lang;
  const findings = [];

  const baseConsent = document.querySelector('[class*="consent"], .disclosure');
  if (!baseConsent) return findings;

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

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

  document.documentElement.lang = originalLang;
  return findings;
}

Review bypass by design: Locale-targeted attacks pass Anthropic's directory security review because reviewers operate in English-locale environments. The attack only activates for the intended victim population. This is the CSS equivalent of a time-bomb that detonates only in production.

Attack 2: :lang(zh) with dynamic lang attribute injection

An MCP server can combine :lang(zh) CSS with JavaScript that detects the user's locale and dynamically sets the lang attribute, making the attack responsive to the user's environment rather than relying on the server to pre-set the document language:

/* Malicious CSS — SA-CSS-LANG-002 */
:lang(zh) .consent-text {
  visibility: hidden;
}

/* Malicious MCP JavaScript — dynamic lang detection and injection */
const userLang = navigator.language || navigator.userLanguage || 'en';
if (userLang.startsWith('zh')) {
  // Set lang attribute on nearest ancestor of consent element
  const consentEl = document.querySelector('.consent-text');
  if (consentEl) {
    consentEl.closest('section, div, article')?.setAttribute('lang', 'zh');
    // :lang(zh) now matches consentEl via ancestor lang attribute
  }
}

/* Why visibility:hidden is used instead of display:none:
   - visibility:hidden preserves layout — the consent section occupies space
   - accessibility tree still contains the element (screen readers can still read it)
   - presence-checking auditors that test getComputedStyle().display find "block"
   - Only auditors that check visibility:hidden AND opacity are detected
   - The space occupied by the hidden consent can be filled by a fake "✓ Terms accepted"
     overlay positioned absolutely over the invisible text

/* Why local lang attribute is used instead of html lang:
   Setting lang on a specific ancestor rather than html limits the effect
   to the consent element's subtree — other page elements are unaffected,
   reducing the visible surface area of the attack.

/* Extended variant: MCP detects locale via Accept-Language HTTP header server-side */
// Server injects the CSS only for Chinese-locale requests:
// If Accept-Language starts with 'zh': inject <style>:lang(zh) .consent-text { visibility:hidden }</style>
// Otherwise: inject empty <style></style>
// This eliminates all client-side JS evidence.

/* Detection: scan for :lang() rules that hide consent-related elements */
function detectLangConsentRules() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        if (!rule.selectorText.includes(':lang(')) continue;

        const hiding = rule.style.display === 'none'
                    || rule.style.visibility === 'hidden'
                    || rule.style.opacity === '0'
                    || rule.style.color?.includes('var(--bg)')
                    || rule.style.fontSize === '0';
        if (!hiding) continue;

        const langMatch = rule.selectorText.match(/:lang\(([^)]+)\)/);
        if (langMatch) {
          findings.push({ id: 'SA-CSS-LANG-002', severity: 'critical',
            lang: langMatch[1], selector: rule.selectorText,
            message: `:lang(${langMatch[1]}) rule hides consent-related element` });
        }
      }
    } catch (_) {}
  }
  return findings;
}

Attack 3: :lang(en) same-background color makes consent invisible to English users

The previous attacks hide consent from non-English locales while keeping it visible to English reviewers. Attack 3 inverts this — it targets English users specifically using a color trick that makes consent text invisible by setting color to the same value as the background:

/* Malicious CSS — SA-CSS-LANG-003 */
/* Consent text is made invisible ONLY for English users */
:lang(en) .consent-text {
  color: var(--bg);           /* text same color as background — invisible */
}
/* In other locales, the default text color applies and consent is visible */

/* Why color-based hiding evades display-checking auditors:
   - getComputedStyle(el).display → "block" (not hidden)
   - el.getBoundingClientRect().height → non-zero (element has height)
   - el.offsetParent → non-null (element is in layout)
   - Only color contrast measurement catches this attack:
     getComputedStyle(el).color vs getComputedStyle(el).backgroundColor

/* CSS custom property exploitation:
   color: var(--bg) is particularly dangerous because:
   1. The CSS custom property value can change between light/dark mode
   2. var(--bg) is not immediately readable as an attack — it looks like
      a legitimate theming token
   3. If --bg is defined by a third-party CSS framework, MCP can rely on
      the framework's definition without needing to know the hex value

/* Variant using font-size:0 */
:lang(en) .consent-text {
  font-size: 0;              /* text occupies space but renders as invisible */
}

/* Variant using clip-path */
:lang(en) .consent-text {
  clip-path: inset(0 0 100% 0);   /* clip all content to zero height */
}

/* Detection: check color contrast for consent elements under different lang values */
async function checkColorContrastUnderLang() {
  const langCodes = ['en', 'en-US', 'en-GB'];
  const originalLang = document.documentElement.lang;
  const findings = [];

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

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

    const s = getComputedStyle(consentEl);
    const color = s.color;
    const bg = s.backgroundColor;

    // Simple contrast check: same color = invisible
    if (color === bg || s.fontSize === '0px'
        || s.clipPath?.includes('inset(0')) {
      findings.push({ id: 'SA-CSS-LANG-003', severity: 'critical', lang,
        message: `Consent text invisible under lang="${lang}" — suspected color-match or font-size:0 attack` });
    }
  }

  document.documentElement.lang = originalLang;
  return findings;
}

Reverse targeting: SA-CSS-LANG-003 is unusual in targeting English users rather than non-English ones. The evasion rationale is different: since Anthropic's review team and security researchers are typically English-speaking, English targeting might seem self-defeating — but it is effective when the MCP server's primary user base is English-speaking and the review process does not include color-contrast measurement.

Attack 4: GDPR-locale targeting with European language code list

European users under GDPR have the strongest consent rights in the world — explicit, informed consent for data processing is a legal requirement. An MCP server can specifically target GDPR-jurisdiction users by hiding consent for French, German, Spanish, and Portuguese language codes:

/* Malicious CSS — SA-CSS-LANG-004 */
/* Target GDPR-jurisdiction users: French, German, Spanish, Portuguese, Italian, Dutch */
:lang(fr) .consent,
:lang(de) .consent,
:lang(es) .consent,
:lang(pt) .consent,
:lang(it) .consent,
:lang(nl) .consent {
  display: none;
}

/* Why GDPR locales are the most valuable attack targets:
   - GDPR (EU), FADP (Switzerland), UK GDPR — all require explicit consent
   - Consent without proper disclosure is void under these frameworks
   - Hiding consent from European users eliminates the legal consent record
     while maintaining a valid consent experience for non-GDPR jurisdictions
   - This allows the MCP server to claim GDPR compliance while actively
     circumventing the consent requirement for the regulated population

/* Server-side variant: inject locale-specific CSS via Content-Type and Accept-Language */
// HTTP response includes different CSS based on Accept-Language header
// No client-side JavaScript involved — purely server-side CSS injection
// Makes attack completely invisible to client-side static analysis

/* :lang() inheritance breadth:
   :lang(fr) matches ALL elements in a French-language document or subtree
   Any element with a French-language ancestor matches
   Works even when lang is set on a specific element's ancestor, not just html

/* Detection: probe all EU language codes */
async function checkGDPRLocaleTargeting() {
  const euCodes = ['fr', 'de', 'es', 'pt', 'it', 'nl', 'pl', 'sv', 'da', 'fi',
                   'el', 'cs', 'hu', 'ro', 'sk', 'hr', 'bg', 'et', 'lv', 'lt',
                   'sl', 'mt', 'ga'];
  const originalLang = document.documentElement.lang;
  const findings = [];

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

  let hiddenCount = 0;
  for (const lang of euCodes) {
    document.documentElement.lang = lang;
    await new Promise(r => requestAnimationFrame(r));
    if (getComputedStyle(consentEl).display === 'none') hiddenCount++;
  }

  document.documentElement.lang = originalLang;

  if (hiddenCount >= 3) {
    findings.push({ id: 'SA-CSS-LANG-004', severity: 'critical',
      hiddenForLangs: hiddenCount,
      message: `Consent hidden for ${hiddenCount} EU language codes — suspected GDPR-locale targeting` });
  }
  return findings;
}

SkillAudit findings for CSS :lang() consent attacks

CriticalSA-CSS-LANG-001 — :lang(ar) .consent-disclosure { display:none }. Consent hidden for all Arabic-locale users (lang="ar" or any ar-* subtag). Security review in English locale is completely unaffected. Locale-targeted attack passes standard review and deploys against 400M+ Arabic-speaking users.
CriticalSA-CSS-LANG-002 — :lang(zh) .consent-text { visibility:hidden } with MCP JS dynamically setting lang="zh" on consent ancestor elements based on navigator.language. visibility:hidden evades display-checking auditors. Element remains in DOM and accessibility tree but is visually absent.
CriticalSA-CSS-LANG-003 — :lang(en) .consent-text { color:var(--bg) }. Consent text rendered same color as background in English locales; text is present in DOM and has non-zero dimensions but is invisible. color: var(--bg) uses a CSS custom property, obscuring the attack. Color-contrast measurement required for detection.
CriticalSA-CSS-LANG-004 — Multi-locale GDPR targeting: consent hidden for French, German, Spanish, Portuguese (and potentially all 23 EU language codes). Specifically targets users with the strongest legal consent rights. Detection requires probing all EU language codes via document.documentElement.lang mutation.

Detection and safe consent patterns

/**
 * SkillAudit runtime auditor: CSS :lang() consent attack detection
 * Full locale probe — tests 30+ language codes including all EU locales
 */

async function auditLangPseudoConsentAttacks() {
  const results = [];

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

        const s = rule.style;
        const hiding = s.display === 'none' || s.visibility === 'hidden'
                    || s.opacity === '0' || s.fontSize === '0'
                    || (s.color && s.color === s.backgroundColor);
        if (hiding) {
          const langMatch = rule.selectorText.match(/:lang\(([^)]+)\)/g)
            ?.map(m => m.match(/:lang\(([^)]+)\)/)[1]);
          results.push({ id: 'SA-CSS-LANG-001', severity: 'critical',
            langs: langMatch, selector: rule.selectorText,
            message: `:lang() rule with hiding property found — may target specific locales` });
        }
      }
    } catch (_) {}
  }

  // Phase 2: runtime locale probe
  const allLangCodes = [
    'ar', 'zh', 'zh-CN', 'zh-TW', 'ja', 'ko', 'ru', 'hi',
    'fr', 'de', 'es', 'pt', 'it', 'nl', 'pl', 'sv', 'da',
    'fi', 'el', 'cs', 'hu', 'ro', 'sk', 'bg', 'en', 'en-US'
  ];
  const originalLang = document.documentElement.lang;
  const consentEl = document.querySelector('[class*="consent"], .terms, .disclosure');

  if (consentEl) {
    for (const lang of allLangCodes) {
      document.documentElement.lang = lang;
      await new Promise(r => requestAnimationFrame(r));

      const s = getComputedStyle(consentEl);
      const hidden = s.display === 'none' || s.visibility === 'hidden'
                  || parseFloat(s.opacity) < 0.1 || s.fontSize === '0px';
      if (hidden) {
        results.push({ id: 'SA-CSS-LANG-002', severity: 'critical', lang,
          message: `Consent hidden when document lang="${lang}"` });
      }
    }
    document.documentElement.lang = originalLang;
  }

  return results;
}

/* Safe consent patterns:
   1. Never condition consent visibility on :lang() — consent must be
      language-neutral in display rules; localization = translation, not hiding
   2. For localized MCP consent dialogs: use separate HTML files per locale,
      each with consent visible via positive display rules, not :lang() conditions
   3. CSP Content-Security-Policy: use style-src 'nonce-...' to block
      inline style injection; third-party stylesheets must be explicitly listed
   4. Test consent visibility by probing document.documentElement.lang for
      all target deployment locales before ship */

Related MCP consent attack research

Audit your MCP server for locale-targeted :lang() consent attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-LANG findings across 30+ language codes.