177 lines
6.1 KiB
Python
177 lines
6.1 KiB
Python
"""Filesystem path safety primitives for resource handlers.
|
|
|
|
These functions help MCP servers reject paths that would resolve
|
|
outside the served root when extracted URI template parameters are
|
|
used in filesystem operations. They are standalone utilities usable from both the
|
|
high-level :class:`~mcp.server.mcpserver.MCPServer` and lowlevel server
|
|
implementations.
|
|
|
|
The canonical safe pattern::
|
|
|
|
from mcp.shared.path_security import safe_join
|
|
|
|
@mcp.resource("file://docs/{+path}")
|
|
def read_doc(path: str) -> str:
|
|
return safe_join("/data/docs", path).read_text(encoding="utf-8")
|
|
"""
|
|
|
|
import string
|
|
from pathlib import Path
|
|
|
|
__all__ = ["PathEscapeError", "contains_path_traversal", "is_absolute_path", "safe_join"]
|
|
|
|
|
|
class PathEscapeError(ValueError):
|
|
"""Raised by :func:`safe_join` when the resolved path escapes the base."""
|
|
|
|
|
|
def contains_path_traversal(value: str) -> bool:
|
|
r"""Check whether a value, treated as a relative path, escapes its origin.
|
|
|
|
This is a **base-free** check: it does not know the sandbox root, so
|
|
it detects only whether ``..`` components would move above the
|
|
starting point. Use :func:`safe_join` when you know the root — it
|
|
additionally catches symlink escapes and absolute-path injection.
|
|
|
|
Note:
|
|
This is a string-level check on the value as supplied. It does
|
|
not model platform-specific filesystem normalisation (e.g. Win32
|
|
stripping of trailing dots and spaces from the final path
|
|
component). For filesystem access, use :func:`safe_join`, which
|
|
resolves through the OS and verifies containment.
|
|
|
|
The check is component-based: ``..`` is dangerous only as a
|
|
standalone path segment, not as a substring. Both ``/`` and ``\``
|
|
are treated as separators.
|
|
|
|
Example::
|
|
|
|
>>> contains_path_traversal("a/b/c")
|
|
False
|
|
>>> contains_path_traversal("../etc")
|
|
True
|
|
>>> contains_path_traversal("a/../../b")
|
|
True
|
|
>>> contains_path_traversal("a/../b")
|
|
False
|
|
>>> contains_path_traversal("1.0..2.0")
|
|
False
|
|
>>> contains_path_traversal("..")
|
|
True
|
|
|
|
Args:
|
|
value: A string that may be used as a filesystem path.
|
|
|
|
Returns:
|
|
``True`` if the path would escape its starting directory.
|
|
"""
|
|
depth = 0
|
|
for part in value.replace("\\", "/").split("/"):
|
|
if part == "..":
|
|
depth -= 1
|
|
if depth < 0:
|
|
return True
|
|
elif part and part != ".":
|
|
depth += 1
|
|
return False
|
|
|
|
|
|
def is_absolute_path(value: str) -> bool:
|
|
r"""Check whether a value is an absolute filesystem path.
|
|
|
|
Absolute paths are dangerous when joined onto a base: in Python,
|
|
``Path("/data") / "/etc/passwd"`` yields ``/etc/passwd`` — the
|
|
absolute right-hand side silently discards the base.
|
|
|
|
Detects POSIX absolute (``/foo``), Windows drive-absolute
|
|
(``C:\foo``) and drive-relative (``C:foo``), and Windows
|
|
UNC/root-relative (``\\server\share``, ``\foo``).
|
|
|
|
Example::
|
|
|
|
>>> is_absolute_path("relative/path")
|
|
False
|
|
>>> is_absolute_path("/etc/passwd")
|
|
True
|
|
>>> is_absolute_path("C:\\Windows")
|
|
True
|
|
>>> is_absolute_path("")
|
|
False
|
|
|
|
Args:
|
|
value: A string that may be used as a filesystem path.
|
|
|
|
Returns:
|
|
``True`` if the path is absolute on any common platform.
|
|
"""
|
|
if not value:
|
|
return False
|
|
if value[0] in ("/", "\\"):
|
|
return True
|
|
# Windows drive form: C:, C:\, C:foo (drive-relative). A drive-
|
|
# relative right-hand side discards the join base when drives
|
|
# differ, so flag it even though PureWindowsPath.is_absolute()
|
|
# is False. This means single-letter-prefixed identifiers like
|
|
# "x:y" also match — opt out via ResourceSecurity(exempt_params=).
|
|
if len(value) >= 2 and value[1] == ":" and value[0] in string.ascii_letters:
|
|
return True
|
|
return False
|
|
|
|
|
|
def safe_join(base: str | Path, *parts: str) -> Path:
|
|
"""Join path components onto a base, rejecting escapes.
|
|
|
|
Resolves the joined path and verifies it remains within ``base``.
|
|
This is the **gold-standard** check: it catches ``..`` traversal,
|
|
absolute-path injection, and symlink escapes that the base-free
|
|
checks cannot.
|
|
|
|
The symlink check is point-in-time: a directory swapped for a
|
|
symlink between this call and the caller's subsequent open would not
|
|
be re-checked. Handlers serving a tree that may be modified
|
|
concurrently should additionally open with ``O_NOFOLLOW`` or use
|
|
platform path-confinement primitives.
|
|
|
|
Example::
|
|
|
|
>>> safe_join("/data/docs", "readme.txt")
|
|
PosixPath('/data/docs/readme.txt')
|
|
>>> safe_join("/data/docs", "../../../etc/passwd")
|
|
Traceback (most recent call last):
|
|
...
|
|
PathEscapeError: ...
|
|
|
|
Args:
|
|
base: The sandbox root. May be relative; it will be resolved.
|
|
parts: Path components to join. Each is checked for null bytes
|
|
and absolute form before joining.
|
|
|
|
Returns:
|
|
The resolved path, verified to be within ``base`` at resolution
|
|
time.
|
|
|
|
Raises:
|
|
PathEscapeError: If any part contains a null byte, any part is
|
|
absolute, or the resolved path is not contained within the
|
|
resolved base.
|
|
"""
|
|
base_resolved = Path(base).resolve()
|
|
|
|
for part in parts:
|
|
# Null bytes pass through Path construction but fail at the
|
|
# syscall boundary with a cryptic error. Reject here so callers
|
|
# get a clear PathEscapeError instead.
|
|
if "\0" in part:
|
|
raise PathEscapeError(f"Path component contains a null byte; refusing to join onto {base_resolved}")
|
|
# Absolute parts would silently discard everything to the left
|
|
# in Path's / operator.
|
|
if is_absolute_path(part):
|
|
raise PathEscapeError(f"Path component {part!r} is absolute; refusing to join onto {base_resolved}")
|
|
|
|
target = base_resolved.joinpath(*parts).resolve()
|
|
|
|
if not target.is_relative_to(base_resolved):
|
|
raise PathEscapeError(f"Path {target} escapes base {base_resolved}")
|
|
|
|
return target
|