Skip to content

Commit ddeb025

Browse files
fix: detect Unicode homoglyphs and bidirectional chars in tool names
Closes a spoofing attack vector where tool names containing visually identical but distinct Unicode characters (Cyrillic А vs Latin A, fullwidth a vs ASCII a, Greek Α vs Latin A) or invisible bidirectional formatting characters (U+202E RTL override, U+200F RTL mark, etc.) could bypass the existing ASCII-only regex check undetected. Changes to src/mcp/shared/tool_name_validation.py: - Add BIDIRECTIONAL_FORMATTING_CHARS set (12 Unicode control chars) - Reject names containing any bidi formatting char with a descriptive warning - Reject names whose NFKC-normalized form differs from the original, catching fullwidth look-alikes (a→a) before the regex runs Changes to tests/shared/test_tool_name_validation.py: - Add TestUnicodeHomoglyphDetection (9 tests): Cyrillic А/О/Е/Р, fullwidth, Greek Α, RTL/LTR override characters - Add TestUnicodeNormalizationBypass (2 tests): NFD decomposed forms, NFKC fullwidth normalization - Add TestUnicodeBoundaryConditions (1 test): zero-width space - Add TestValidAsciiStillWorks (5 tests): confirm existing valid names still pass after new checks
1 parent 3a6f299 commit ddeb025

2 files changed

Lines changed: 170 additions & 0 deletions

File tree

src/mcp/shared/tool_name_validation.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import logging
1515
import re
16+
import unicodedata
1617
from dataclasses import dataclass, field
1718

1819
logger = logging.getLogger(__name__)
@@ -23,6 +24,22 @@
2324
# SEP reference URL for warning messages
2425
SEP_986_URL = "https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names"
2526

27+
# Bidirectional formatting characters that can be used to obfuscate tool names
28+
BIDIRECTIONAL_FORMATTING_CHARS = {
29+
"‎", # LEFT-TO-RIGHT MARK
30+
"‏", # RIGHT-TO-LEFT MARK
31+
"‪", # LEFT-TO-RIGHT EMBEDDING
32+
"‫", # RIGHT-TO-LEFT EMBEDDING
33+
"‬", # POP DIRECTIONAL FORMATTING
34+
"‭", # LEFT-TO-RIGHT OVERRIDE
35+
"‮", # RIGHT-TO-LEFT OVERRIDE
36+
"⁦", # LEFT-TO-RIGHT ISOLATE
37+
"⁧", # RIGHT-TO-LEFT ISOLATE
38+
"⁨", # FIRST STRONG ISOLATE
39+
"⁩", # POP DIRECTIONAL ISOLATE
40+
"؜", # ARABIC LETTER MARK
41+
}
42+
2643

2744
@dataclass
2845
class ToolNameValidationResult:
@@ -62,6 +79,28 @@ def validate_tool_name(name: str) -> ToolNameValidationResult:
6279
warnings=[f"Tool name exceeds maximum length of 128 characters (current: {len(name)})"],
6380
)
6481

82+
# Check for bidirectional formatting characters (invisible obfuscation)
83+
for char in name:
84+
if char in BIDIRECTIONAL_FORMATTING_CHARS:
85+
warnings.append(
86+
f"Tool name contains bidirectional formatting character ({repr(char)}). "
87+
"This could be used to obfuscate the tool name's visual representation."
88+
)
89+
return ToolNameValidationResult(is_valid=False, warnings=warnings)
90+
91+
# Check for Unicode normalization issues (homoglyphs and confusable characters)
92+
# NFKC is the compatibility normalization form that converts:
93+
# - Fullwidth characters to ASCII (a → a)
94+
# - Composed characters to canonical form
95+
normalized = unicodedata.normalize("NFKC", name)
96+
if normalized != name:
97+
warnings.append(
98+
f"Tool name contains Unicode characters that normalize to a different form. "
99+
f"This may indicate use of homoglyphs or confusable characters. "
100+
f"Original: {repr(name)}, Normalized: {repr(normalized)}"
101+
)
102+
return ToolNameValidationResult(is_valid=False, warnings=warnings)
103+
65104
# Check for problematic patterns (warnings, not validation failures)
66105
if " " in name:
67106
warnings.append("Tool name contains spaces, which may cause parsing issues")

tests/shared/test_tool_name_validation.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,134 @@ def test_edge_cases(tool_name: str, is_valid: bool, expected_warning_fragment: s
211211
result = validate_tool_name(tool_name)
212212
assert result.is_valid is is_valid
213213
assert any(expected_warning_fragment in w for w in result.warnings)
214+
215+
216+
# ============================================================================
217+
# NEW TESTS: Unicode Homoglyph & Confusable Character Detection
218+
# ============================================================================
219+
# These tests verify that the validator detects Unicode homoglyphs
220+
# (characters that look identical but are different) that could be used
221+
# for tool name spoofing attacks.
222+
223+
224+
class TestUnicodeHomoglyphDetection:
225+
"""Tests for detecting Unicode homoglyphs that visually impersonate ASCII."""
226+
227+
def test_cyrillic_a_rejected(self) -> None:
228+
"""Cyrillic А (U+0410) looks identical to Latin A but must be rejected.
229+
230+
Note: NFKC does not map Cyrillic to Latin lookalikes, so this character
231+
is caught by the ASCII-only regex check (invalid characters), not by the
232+
normalization check. The rejection is the important invariant here.
233+
"""
234+
result = validate_tool_name("tool_А")
235+
assert not result.is_valid, "Cyrillic А should be rejected"
236+
assert any("invalid characters" in w.lower() for w in result.warnings), (
237+
f"Expected invalid-character warning. Got: {result.warnings}"
238+
)
239+
240+
def test_cyrillic_o_rejected(self) -> None:
241+
"""Cyrillic О (U+041E) looks identical to Latin O."""
242+
result = validate_tool_name("tool_О")
243+
assert not result.is_valid
244+
245+
def test_cyrillic_e_rejected(self) -> None:
246+
"""Cyrillic Е (U+0415) looks identical to Latin E."""
247+
result = validate_tool_name("tool_Е")
248+
assert not result.is_valid
249+
250+
def test_cyrillic_p_rejected(self) -> None:
251+
"""Cyrillic Р (U+0420) looks identical to Latin P."""
252+
result = validate_tool_name("tool_Р")
253+
assert not result.is_valid
254+
255+
def test_fullwidth_a_rejected(self) -> None:
256+
"""Fullwidth Latin а (U+FF41) looks like ASCII a."""
257+
result = validate_tool_name("atool")
258+
assert not result.is_valid
259+
260+
def test_fullwidth_mixed_rejected(self) -> None:
261+
"""Mix of fullwidth and ASCII should be detected."""
262+
result = validate_tool_name("tool") # 'о' is fullwidth
263+
assert not result.is_valid
264+
265+
def test_greek_alpha_rejected(self) -> None:
266+
"""Greek Α (U+0391) looks like Latin A."""
267+
result = validate_tool_name("tool_Α")
268+
assert not result.is_valid
269+
270+
def test_rtl_override_rejected(self) -> None:
271+
"""Right-to-left override character (U+202E) should be rejected."""
272+
result = validate_tool_name("tool‮_name") # RIGHT-TO-LEFT OVERRIDE
273+
assert not result.is_valid
274+
assert any("directional" in w.lower() or "rtl" in w.lower() for w in result.warnings), (
275+
f"Warning should mention directional formatting. Got: {result.warnings}"
276+
)
277+
278+
def test_ltr_override_rejected(self) -> None:
279+
"""Left-to-right override character (U+202D) should be rejected."""
280+
result = validate_tool_name("tool‭_name") # LEFT-TO-RIGHT OVERRIDE
281+
assert not result.is_valid
282+
283+
284+
class TestUnicodeNormalizationBypass:
285+
"""Tests for Unicode normalization form attacks."""
286+
287+
def test_decomposed_unicode_detected(self) -> None:
288+
"""Decomposed Unicode should be normalized and detected."""
289+
import unicodedata
290+
291+
# 'café' in decomposed form (e + combining accent)
292+
decomposed = unicodedata.normalize("NFD", "café")
293+
result = validate_tool_name(f"tool_{decomposed}")
294+
# Should either be rejected or warn
295+
assert not result.is_valid or any("normalize" in w.lower() for w in result.warnings), (
296+
f"Decomposed forms should be detected. Got is_valid={result.is_valid}, warnings={result.warnings}"
297+
)
298+
299+
def test_nfkc_normalization_detected(self) -> None:
300+
"""NFKC normalization changes should be detected.
301+
302+
Fullwidth ASCII (e.g. abc) always normalizes under NFKC, so the
303+
validator must flag it unconditionally.
304+
"""
305+
# Fullwidth characters always normalize to ASCII under NFKC
306+
original = "abc" # Fullwidth ASCII — NFKC maps these to 'abc'
307+
result = validate_tool_name(original)
308+
assert not result.is_valid or any("normalize" in w.lower() for w in result.warnings), (
309+
"NFKC normalization should be detected"
310+
)
311+
312+
313+
class TestUnicodeBoundaryConditions:
314+
"""Edge cases and boundary conditions."""
315+
316+
def test_zero_width_characters_rejected(self) -> None:
317+
"""Zero-width characters should be rejected."""
318+
result = validate_tool_name("tool​_name") # Zero-width space
319+
assert not result.is_valid
320+
321+
322+
class TestValidAsciiStillWorks:
323+
"""Ensure valid ASCII tool names still pass after homoglyph detection added."""
324+
325+
def test_basic_ascii_passes(self) -> None:
326+
"""Standard ASCII tool names should still pass."""
327+
result = validate_tool_name("verify_user")
328+
assert result.is_valid
329+
330+
def test_numbers_pass(self) -> None:
331+
result = validate_tool_name("tool_123")
332+
assert result.is_valid
333+
334+
def test_dashes_underscores_pass(self) -> None:
335+
result = validate_tool_name("my-tool_name-v2")
336+
assert result.is_valid
337+
338+
def test_dots_pass(self) -> None:
339+
result = validate_tool_name("tool.extension")
340+
assert result.is_valid
341+
342+
def test_long_valid_name_passes(self) -> None:
343+
result = validate_tool_name("a" * 128) # Max length, all valid
344+
assert result.is_valid

0 commit comments

Comments
 (0)