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 pattern | Targeted population | Evasion 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 text | Consent 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 rights | Targets 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
: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.: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.: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.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
- CSS :dir() directionality pseudo-class consent attacks
- CSS :scope/:root/:host shadow DOM consent attacks
- CSS :link/:visited/:any-link consent attacks
- CSS :is() and :where() forgiving selector attacks
- CSS Pseudo-Class Attacks on MCP Consent (overview)
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.