Files
modelcontextprotocol--pytho…/tests/shared/test_path_security.py
Max Isbey ec63c200fc hardening: tighten limits, immutability, and drive-letter detection
Bundled low-severity hardening:

- Lower DEFAULT_MAX_TEMPLATE_LENGTH from 1MB to 8KB. Real templates
  are under 200 bytes; the old limit allowed 0.75s parse times.
- Replace max_expressions with max_variables (default 256). A single
  {v0,v1,...,vN} expression packed arbitrarily many variables under
  one expression count, bypassing the limit.
- Store UriTemplate internals as tuples. The dataclass is frozen but
  list fields were mutable via t._parts.append(), violating the
  immutability contract.
- Coerce ResourceSecurity.exempt_params to frozenset in __post_init__
  so hash() works even when callers pass a regular set.
- Check drive letters against ASCII only. str.isalpha() is
  Unicode-aware, so is_absolute_path("Ω:foo") falsely returned True.
2026-03-27 15:44:03 +00:00

156 lines
4.4 KiB
Python

"""Tests for filesystem path safety primitives."""
from pathlib import Path
import pytest
from mcp.shared.path_security import (
PathEscapeError,
contains_path_traversal,
is_absolute_path,
safe_join,
)
@pytest.mark.parametrize(
("value", "expected"),
[
# Safe: no traversal
("a/b/c", False),
("readme.txt", False),
("", False),
(".", False),
("./a/b", False),
# Safe: .. balanced by prior descent
("a/../b", False),
("a/b/../c", False),
("a/b/../../c", False),
# Unsafe: net escape
("..", True),
("../etc", True),
("../../etc/passwd", True),
("a/../../b", True),
("./../../etc", True),
# .. as substring, not component — safe
("1.0..2.0", False),
("foo..bar", False),
("..foo", False),
("foo..", False),
# Backslash separator
("..\\etc", True),
("a\\..\\..\\b", True),
("a\\b\\c", False),
# Mixed separators
("a/..\\..\\b", True),
],
)
def test_contains_path_traversal(value: str, expected: bool):
assert contains_path_traversal(value) is expected
@pytest.mark.parametrize(
("value", "expected"),
[
# Relative
("relative/path", False),
("file.txt", False),
("", False),
(".", False),
("..", False),
# POSIX absolute
("/", True),
("/etc/passwd", True),
("/a", True),
# Windows drive
("C:", True),
("C:\\Windows", True),
("c:/foo", True),
("Z:\\", True),
# Windows UNC / backslash-absolute
("\\\\server\\share", True),
("\\foo", True),
# Not a drive: digit before colon
("1:foo", False),
# Colon not in position 1
("ab:c", False),
# Non-ASCII letter is not a drive letter
("Ω:namespace", False),
("é:foo", False),
],
)
def test_is_absolute_path(value: str, expected: bool):
assert is_absolute_path(value) is expected
def test_safe_join_simple(tmp_path: Path):
result = safe_join(tmp_path, "docs", "readme.txt")
assert result == tmp_path / "docs" / "readme.txt"
def test_safe_join_resolves_relative_base(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.chdir(tmp_path)
result = safe_join(".", "file.txt")
assert result == tmp_path / "file.txt"
def test_safe_join_rejects_dotdot_escape(tmp_path: Path):
with pytest.raises(PathEscapeError, match="escapes base"):
safe_join(tmp_path, "../../../etc/passwd")
def test_safe_join_rejects_balanced_then_escape(tmp_path: Path):
with pytest.raises(PathEscapeError, match="escapes base"):
safe_join(tmp_path, "a/../../etc")
def test_safe_join_allows_balanced_dotdot(tmp_path: Path):
result = safe_join(tmp_path, "a/../b")
assert result == tmp_path / "b"
def test_safe_join_rejects_absolute_part(tmp_path: Path):
with pytest.raises(PathEscapeError, match="is absolute"):
safe_join(tmp_path, "/etc/passwd")
def test_safe_join_rejects_absolute_in_later_part(tmp_path: Path):
with pytest.raises(PathEscapeError, match="is absolute"):
safe_join(tmp_path, "docs", "/etc/passwd")
def test_safe_join_rejects_windows_drive(tmp_path: Path):
with pytest.raises(PathEscapeError, match="is absolute"):
safe_join(tmp_path, "C:\\Windows\\System32")
def test_safe_join_rejects_null_byte(tmp_path: Path):
with pytest.raises(PathEscapeError, match="null byte"):
safe_join(tmp_path, "file\0.txt")
def test_safe_join_rejects_null_byte_in_later_part(tmp_path: Path):
with pytest.raises(PathEscapeError, match="null byte"):
safe_join(tmp_path, "docs", "file\0.txt")
def test_safe_join_rejects_symlink_escape(tmp_path: Path):
outside = tmp_path / "outside"
outside.mkdir()
sandbox = tmp_path / "sandbox"
sandbox.mkdir()
(sandbox / "escape").symlink_to(outside)
with pytest.raises(PathEscapeError, match="escapes base"):
safe_join(sandbox, "escape", "secret.txt")
def test_safe_join_base_equals_target(tmp_path: Path):
# Joining nothing (or ".") should return the base itself
assert safe_join(tmp_path) == tmp_path
assert safe_join(tmp_path, ".") == tmp_path
def test_path_escape_error_is_value_error():
with pytest.raises(ValueError):
safe_join("/tmp", "/etc")