49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
160 lines
4.5 KiB
Python
160 lines
4.5 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),
|
|
# Windows drive-relative — discards the join base when drives differ
|
|
("C:relative", True),
|
|
("x:y", True),
|
|
("a:debug", 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")
|