Skip to content

Commit c4f7db0

Browse files
committed
feat: reject null bytes in safe_join path components
Null bytes pass through Path construction but fail at the syscall boundary with a cryptic 'embedded null byte' error. Rejecting in safe_join gives callers a clear PathEscapeError instead, and guards against null-byte injection when the path is used for anything other than immediate file I/O (logging, subprocess args, config).
1 parent 3a786f3 commit c4f7db0

File tree

2 files changed

+22
-6
lines changed

2 files changed

+22
-6
lines changed

src/mcp/shared/path_security.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,21 +124,27 @@ def safe_join(base: str | Path, *parts: str) -> Path:
124124
125125
Args:
126126
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.
127+
parts: Path components to join. Each is checked for null bytes
128+
and absolute form before joining.
129129
130130
Returns:
131131
The resolved path, guaranteed to be within ``base``.
132132
133133
Raises:
134-
PathEscapeError: If any part is absolute, or if the resolved
135-
path is not contained within the resolved base.
134+
PathEscapeError: If any part contains a null byte, any part is
135+
absolute, or the resolved path is not contained within the
136+
resolved base.
136137
"""
137138
base_resolved = Path(base).resolve()
138139

139-
# Reject absolute parts up front: Path's / operator would silently
140-
# discard everything to the left of an absolute component.
141140
for part in parts:
141+
# Null bytes pass through Path construction but fail at the
142+
# syscall boundary with a cryptic error. Reject here so callers
143+
# get a clear PathEscapeError instead.
144+
if "\0" in part:
145+
raise PathEscapeError(f"Path component contains a null byte; refusing to join onto {base_resolved}")
146+
# Absolute parts would silently discard everything to the left
147+
# in Path's / operator.
142148
if is_absolute_path(part):
143149
raise PathEscapeError(f"Path component {part!r} is absolute; refusing to join onto {base_resolved}")
144150

tests/shared/test_path_security.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,16 @@ def test_safe_join_rejects_windows_drive(tmp_path: Path):
120120
safe_join(tmp_path, "C:\\Windows\\System32")
121121

122122

123+
def test_safe_join_rejects_null_byte(tmp_path: Path):
124+
with pytest.raises(PathEscapeError, match="null byte"):
125+
safe_join(tmp_path, "file\0.txt")
126+
127+
128+
def test_safe_join_rejects_null_byte_in_later_part(tmp_path: Path):
129+
with pytest.raises(PathEscapeError, match="null byte"):
130+
safe_join(tmp_path, "docs", "file\0.txt")
131+
132+
123133
def test_safe_join_rejects_symlink_escape(tmp_path: Path):
124134
outside = tmp_path / "outside"
125135
outside.mkdir()

0 commit comments

Comments
 (0)