Skip to content

Commit e5ecf50

Browse files
committed
feat: add filesystem path safety primitives
Adds `mcp.shared.path_security` with three standalone utilities for defending against path-traversal attacks when URI template parameters flow into filesystem operations: - `contains_path_traversal()` — base-free component-level check for `..` escapes, handles both `/` and `\` separators - `is_absolute_path()` — detects POSIX, Windows drive, and UNC absolute paths (which silently discard the base in `Path` joins) - `safe_join()` — resolve-and-verify within a sandbox root; catches `..`, absolute injection, and symlink escapes These are pure functions usable from both MCPServer and lowlevel server implementations. `PathEscapeError(ValueError)` is raised by `safe_join` on violation.
1 parent 5f5e72b commit e5ecf50

File tree

2 files changed

+292
-0
lines changed

2 files changed

+292
-0
lines changed

src/mcp/shared/path_security.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Filesystem path safety primitives for resource handlers.
2+
3+
These functions help MCP servers defend against path-traversal attacks
4+
when extracted URI template parameters are used in filesystem
5+
operations. They are standalone utilities usable from both the
6+
high-level :class:`~mcp.server.mcpserver.MCPServer` and lowlevel server
7+
implementations.
8+
9+
The canonical safe pattern::
10+
11+
from mcp.shared.path_security import safe_join
12+
13+
@mcp.resource("file://docs/{+path}")
14+
def read_doc(path: str) -> str:
15+
return safe_join("/data/docs", path).read_text()
16+
"""
17+
18+
from pathlib import Path
19+
20+
__all__ = ["PathEscapeError", "contains_path_traversal", "is_absolute_path", "safe_join"]
21+
22+
23+
class PathEscapeError(ValueError):
24+
"""Raised by :func:`safe_join` when the resolved path escapes the base."""
25+
26+
27+
def contains_path_traversal(value: str) -> bool:
28+
r"""Check whether a value, treated as a relative path, escapes its origin.
29+
30+
This is a **base-free** check: it does not know the sandbox root, so
31+
it detects only whether ``..`` components would move above the
32+
starting point. Use :func:`safe_join` when you know the root — it
33+
additionally catches symlink escapes and absolute-path injection.
34+
35+
The check is component-based: ``..`` is dangerous only as a
36+
standalone path segment, not as a substring. Both ``/`` and ``\``
37+
are treated as separators.
38+
39+
Example::
40+
41+
>>> contains_path_traversal("a/b/c")
42+
False
43+
>>> contains_path_traversal("../etc")
44+
True
45+
>>> contains_path_traversal("a/../../b")
46+
True
47+
>>> contains_path_traversal("a/../b")
48+
False
49+
>>> contains_path_traversal("1.0..2.0")
50+
False
51+
>>> contains_path_traversal("..")
52+
True
53+
54+
Args:
55+
value: A string that may be used as a filesystem path.
56+
57+
Returns:
58+
``True`` if the path would escape its starting directory.
59+
"""
60+
depth = 0
61+
for part in value.replace("\\", "/").split("/"):
62+
if part == "..":
63+
depth -= 1
64+
if depth < 0:
65+
return True
66+
elif part and part != ".":
67+
depth += 1
68+
return False
69+
70+
71+
def is_absolute_path(value: str) -> bool:
72+
r"""Check whether a value is an absolute filesystem path.
73+
74+
Absolute paths are dangerous when joined onto a base: in Python,
75+
``Path("/data") / "/etc/passwd"`` yields ``/etc/passwd`` — the
76+
absolute right-hand side silently discards the base.
77+
78+
Detects POSIX absolute (``/foo``), Windows drive (``C:\foo``),
79+
and Windows UNC/absolute (``\\server\share``, ``\foo``).
80+
81+
Example::
82+
83+
>>> is_absolute_path("relative/path")
84+
False
85+
>>> is_absolute_path("/etc/passwd")
86+
True
87+
>>> is_absolute_path("C:\\Windows")
88+
True
89+
>>> is_absolute_path("")
90+
False
91+
92+
Args:
93+
value: A string that may be used as a filesystem path.
94+
95+
Returns:
96+
``True`` if the path is absolute on any common platform.
97+
"""
98+
if not value:
99+
return False
100+
if value[0] in ("/", "\\"):
101+
return True
102+
# Windows drive letter: C:, C:\, C:/
103+
if len(value) >= 2 and value[1] == ":" and value[0].isalpha():
104+
return True
105+
return False
106+
107+
108+
def safe_join(base: str | Path, *parts: str) -> Path:
109+
"""Join path components onto a base, rejecting escapes.
110+
111+
Resolves the joined path and verifies it remains within ``base``.
112+
This is the **gold-standard** check: it catches ``..`` traversal,
113+
absolute-path injection, and symlink escapes that the base-free
114+
checks cannot.
115+
116+
Example::
117+
118+
>>> safe_join("/data/docs", "readme.txt")
119+
PosixPath('/data/docs/readme.txt')
120+
>>> safe_join("/data/docs", "../../../etc/passwd")
121+
Traceback (most recent call last):
122+
...
123+
PathEscapeError: ...
124+
125+
Args:
126+
base: The sandbox root. May be relative; it will be resolved.
127+
parts: Path components to join. Each is checked for absolute
128+
form before joining.
129+
130+
Returns:
131+
The resolved path, guaranteed to be within ``base``.
132+
133+
Raises:
134+
PathEscapeError: If any part is absolute, or if the resolved
135+
path is not contained within the resolved base.
136+
"""
137+
base_resolved = Path(base).resolve()
138+
139+
# Reject absolute parts up front: Path's / operator would silently
140+
# discard everything to the left of an absolute component.
141+
for part in parts:
142+
if is_absolute_path(part):
143+
raise PathEscapeError(f"Path component {part!r} is absolute; refusing to join onto {base_resolved}")
144+
145+
target = base_resolved.joinpath(*parts).resolve()
146+
147+
if not target.is_relative_to(base_resolved):
148+
raise PathEscapeError(f"Path {target} escapes base {base_resolved}")
149+
150+
return target

tests/shared/test_path_security.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Tests for filesystem path safety primitives."""
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from mcp.shared.path_security import (
8+
PathEscapeError,
9+
contains_path_traversal,
10+
is_absolute_path,
11+
safe_join,
12+
)
13+
14+
15+
@pytest.mark.parametrize(
16+
("value", "expected"),
17+
[
18+
# Safe: no traversal
19+
("a/b/c", False),
20+
("readme.txt", False),
21+
("", False),
22+
(".", False),
23+
("./a/b", False),
24+
# Safe: .. balanced by prior descent
25+
("a/../b", False),
26+
("a/b/../c", False),
27+
("a/b/../../c", False),
28+
# Unsafe: net escape
29+
("..", True),
30+
("../etc", True),
31+
("../../etc/passwd", True),
32+
("a/../../b", True),
33+
("./../../etc", True),
34+
# .. as substring, not component — safe
35+
("1.0..2.0", False),
36+
("foo..bar", False),
37+
("..foo", False),
38+
("foo..", False),
39+
# Backslash separator
40+
("..\\etc", True),
41+
("a\\..\\..\\b", True),
42+
("a\\b\\c", False),
43+
# Mixed separators
44+
("a/..\\..\\b", True),
45+
],
46+
)
47+
def test_contains_path_traversal(value: str, expected: bool):
48+
assert contains_path_traversal(value) is expected
49+
50+
51+
@pytest.mark.parametrize(
52+
("value", "expected"),
53+
[
54+
# Relative
55+
("relative/path", False),
56+
("file.txt", False),
57+
("", False),
58+
(".", False),
59+
("..", False),
60+
# POSIX absolute
61+
("/", True),
62+
("/etc/passwd", True),
63+
("/a", True),
64+
# Windows drive
65+
("C:", True),
66+
("C:\\Windows", True),
67+
("c:/foo", True),
68+
("Z:\\", True),
69+
# Windows UNC / backslash-absolute
70+
("\\\\server\\share", True),
71+
("\\foo", True),
72+
# Not a drive: digit before colon
73+
("1:foo", False),
74+
# Colon not in position 1
75+
("ab:c", False),
76+
],
77+
)
78+
def test_is_absolute_path(value: str, expected: bool):
79+
assert is_absolute_path(value) is expected
80+
81+
82+
def test_safe_join_simple(tmp_path: Path):
83+
result = safe_join(tmp_path, "docs", "readme.txt")
84+
assert result == tmp_path / "docs" / "readme.txt"
85+
86+
87+
def test_safe_join_resolves_relative_base(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
88+
monkeypatch.chdir(tmp_path)
89+
result = safe_join(".", "file.txt")
90+
assert result == tmp_path / "file.txt"
91+
92+
93+
def test_safe_join_rejects_dotdot_escape(tmp_path: Path):
94+
with pytest.raises(PathEscapeError, match="escapes base"):
95+
safe_join(tmp_path, "../../../etc/passwd")
96+
97+
98+
def test_safe_join_rejects_balanced_then_escape(tmp_path: Path):
99+
with pytest.raises(PathEscapeError, match="escapes base"):
100+
safe_join(tmp_path, "a/../../etc")
101+
102+
103+
def test_safe_join_allows_balanced_dotdot(tmp_path: Path):
104+
result = safe_join(tmp_path, "a/../b")
105+
assert result == tmp_path / "b"
106+
107+
108+
def test_safe_join_rejects_absolute_part(tmp_path: Path):
109+
with pytest.raises(PathEscapeError, match="is absolute"):
110+
safe_join(tmp_path, "/etc/passwd")
111+
112+
113+
def test_safe_join_rejects_absolute_in_later_part(tmp_path: Path):
114+
with pytest.raises(PathEscapeError, match="is absolute"):
115+
safe_join(tmp_path, "docs", "/etc/passwd")
116+
117+
118+
def test_safe_join_rejects_windows_drive(tmp_path: Path):
119+
with pytest.raises(PathEscapeError, match="is absolute"):
120+
safe_join(tmp_path, "C:\\Windows\\System32")
121+
122+
123+
def test_safe_join_rejects_symlink_escape(tmp_path: Path):
124+
outside = tmp_path / "outside"
125+
outside.mkdir()
126+
sandbox = tmp_path / "sandbox"
127+
sandbox.mkdir()
128+
(sandbox / "escape").symlink_to(outside)
129+
130+
with pytest.raises(PathEscapeError, match="escapes base"):
131+
safe_join(sandbox, "escape", "secret.txt")
132+
133+
134+
def test_safe_join_base_equals_target(tmp_path: Path):
135+
# Joining nothing (or ".") should return the base itself
136+
assert safe_join(tmp_path) == tmp_path
137+
assert safe_join(tmp_path, ".") == tmp_path
138+
139+
140+
def test_path_escape_error_is_value_error():
141+
with pytest.raises(ValueError):
142+
safe_join("/tmp", "/etc")

0 commit comments

Comments
 (0)