chore: import upstream snapshot with attribution
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) Has been cancelled
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) Has been cancelled
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user