feat: RFC 6570 URI templates with operator-aware security (#2356)
This commit is contained in:
parent
067f90578c
commit
24717cc8eb
8 changed files with 3800 additions and 37 deletions
|
|
@ -66,6 +66,7 @@ from .client.stdio import StdioServerParameters, stdio_client
|
|||
from .server.session import ServerSession
|
||||
from .server.stdio import stdio_server
|
||||
from .shared.exceptions import MCPDeprecationWarning, MCPError, UrlElicitationRequiredError
|
||||
from .shared.uri_template import InvalidUriTemplate, UriTemplate
|
||||
|
||||
__all__ = [
|
||||
"CallToolRequest",
|
||||
|
|
@ -133,7 +134,9 @@ __all__ = [
|
|||
"ToolsCapability",
|
||||
"ToolUseContent",
|
||||
"UnsubscribeRequest",
|
||||
"UriTemplate",
|
||||
"UrlElicitationRequiredError",
|
||||
"InvalidUriTemplate",
|
||||
"stdio_client",
|
||||
"stdio_server",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,16 @@
|
|||
from mcp_types import Icon
|
||||
|
||||
from .context import Context
|
||||
from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity
|
||||
from .server import MCPServer
|
||||
from .utilities.types import Audio, Image
|
||||
|
||||
__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"]
|
||||
__all__ = [
|
||||
"MCPServer",
|
||||
"Context",
|
||||
"Image",
|
||||
"Audio",
|
||||
"Icon",
|
||||
"ResourceSecurity",
|
||||
"DEFAULT_RESOURCE_SECURITY",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from .base import Resource
|
||||
from .resource_manager import ResourceManager
|
||||
from .templates import ResourceTemplate
|
||||
from .templates import DEFAULT_RESOURCE_SECURITY, ResourceSecurity, ResourceSecurityError, ResourceTemplate
|
||||
from .types import (
|
||||
BinaryResource,
|
||||
DirectoryResource,
|
||||
|
|
@ -20,4 +20,7 @@ __all__ = [
|
|||
"DirectoryResource",
|
||||
"ResourceTemplate",
|
||||
"ResourceManager",
|
||||
"ResourceSecurity",
|
||||
"ResourceSecurityError",
|
||||
"DEFAULT_RESOURCE_SECURITY",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@ from pydantic import AnyUrl
|
|||
|
||||
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
|
||||
from mcp.server.mcpserver.resources.base import Resource
|
||||
from mcp.server.mcpserver.resources.templates import ResourceTemplate
|
||||
from mcp.server.mcpserver.resources.templates import (
|
||||
DEFAULT_RESOURCE_SECURITY,
|
||||
ResourceSecurity,
|
||||
ResourceSecurityError,
|
||||
ResourceTemplate,
|
||||
)
|
||||
from mcp.server.mcpserver.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -63,6 +68,7 @@ class ResourceManager:
|
|||
icons: list[Icon] | None = None,
|
||||
annotations: Annotations | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
|
||||
) -> ResourceTemplate:
|
||||
"""Add a template from a function."""
|
||||
template = ResourceTemplate.from_function(
|
||||
|
|
@ -75,6 +81,7 @@ class ResourceManager:
|
|||
icons=icons,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
security=security,
|
||||
)
|
||||
self._templates[template.uri_template] = template
|
||||
return template
|
||||
|
|
@ -85,6 +92,15 @@ class ResourceManager:
|
|||
Raises:
|
||||
ResourceNotFoundError: If no resource or template matches the URI.
|
||||
ResourceError: If a matching template fails to create the resource.
|
||||
|
||||
Note:
|
||||
Pydantic's ``AnyUrl`` normalises percent-encoding and
|
||||
resolves ``..`` segments during validation, so a value
|
||||
constructed as ``AnyUrl("file:///a/%2E%2E/b")`` arrives
|
||||
here as ``file:///b``. The JSON-RPC protocol layer passes
|
||||
raw ``str`` values and is unaffected, but internal callers
|
||||
wrapping URIs in ``AnyUrl`` should be aware that security
|
||||
checks see the already-normalised form.
|
||||
"""
|
||||
uri_str = str(uri)
|
||||
logger.debug("Getting resource", extra={"uri": uri_str})
|
||||
|
|
@ -95,7 +111,11 @@ class ResourceManager:
|
|||
|
||||
# Then check templates
|
||||
for template in self._templates.values():
|
||||
if params := template.matches(uri_str):
|
||||
try:
|
||||
params = template.matches(uri_str)
|
||||
except ResourceSecurityError as e:
|
||||
raise ResourceNotFoundError(f"Unknown resource: {uri}") from e
|
||||
if params is not None:
|
||||
return await template.create_resource(uri_str, params, context=context)
|
||||
|
||||
raise ResourceNotFoundError(f"Unknown resource: {uri}")
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping, Set
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
import anyio.to_thread
|
||||
from mcp_types import Annotations, Icon
|
||||
|
|
@ -18,6 +17,8 @@ from mcp.server.mcpserver.utilities.context_injection import find_context_parame
|
|||
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
|
||||
from mcp.server.mcpserver.utilities.logging import get_logger
|
||||
from mcp.shared._callable_inspection import is_async_callable
|
||||
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
|
||||
from mcp.shared.uri_template import UriTemplate
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -26,6 +27,82 @@ if TYPE_CHECKING:
|
|||
from mcp.server.mcpserver.context import Context
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceSecurity:
|
||||
"""Security policy applied to extracted resource template parameters.
|
||||
|
||||
These checks run after :meth:`~mcp.shared.uri_template.UriTemplate.match`
|
||||
has extracted and decoded parameter values. They catch path-traversal
|
||||
and absolute-path injection regardless of how the value was encoded in
|
||||
the URI (literal, ``%2F``, ``%5C``, ``%2E%2E``).
|
||||
|
||||
Example::
|
||||
|
||||
# Opt out for a parameter that legitimately contains ..
|
||||
@mcp.resource(
|
||||
"git://diff/{+range}",
|
||||
security=ResourceSecurity(exempt_params={"range"}),
|
||||
)
|
||||
def git_diff(range: str) -> str: ...
|
||||
"""
|
||||
|
||||
reject_path_traversal: bool = True
|
||||
"""Reject values containing ``..`` as a path component."""
|
||||
|
||||
reject_absolute_paths: bool = True
|
||||
"""Reject values that look like absolute filesystem paths."""
|
||||
|
||||
reject_null_bytes: bool = True
|
||||
"""Reject values containing NUL (``\\x00``). Null bytes defeat string
|
||||
comparisons (``"..\\x00" != ".."``) and can cause truncation in C
|
||||
extensions or subprocess calls."""
|
||||
|
||||
exempt_params: Set[str] = field(default_factory=frozenset[str])
|
||||
"""Parameter names to skip all checks for."""
|
||||
|
||||
def validate(self, params: Mapping[str, str | list[str]]) -> str | None:
|
||||
"""Check all parameter values against the configured policy.
|
||||
|
||||
Args:
|
||||
params: Extracted template parameters. List values (from
|
||||
explode variables) are checked element-wise.
|
||||
|
||||
Returns:
|
||||
The name of the first parameter that fails, or ``None`` if
|
||||
all values pass.
|
||||
"""
|
||||
for name, value in params.items():
|
||||
if name in self.exempt_params:
|
||||
continue
|
||||
values = value if isinstance(value, list) else [value]
|
||||
for v in values:
|
||||
if self.reject_null_bytes and "\0" in v:
|
||||
return name
|
||||
if self.reject_path_traversal and contains_path_traversal(v):
|
||||
return name
|
||||
if self.reject_absolute_paths and is_absolute_path(v):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
DEFAULT_RESOURCE_SECURITY = ResourceSecurity()
|
||||
"""Secure-by-default policy: traversal, absolute paths, and null bytes rejected."""
|
||||
|
||||
|
||||
class ResourceSecurityError(ValueError):
|
||||
"""Raised when an extracted parameter fails :class:`ResourceSecurity` checks.
|
||||
|
||||
Distinct from a simple ``None`` non-match so that template
|
||||
iteration can stop at the first security rejection rather than
|
||||
falling through to a later, possibly more permissive, template.
|
||||
"""
|
||||
|
||||
def __init__(self, template: str, param: str) -> None:
|
||||
super().__init__(f"Parameter {param!r} of template {template!r} failed security validation")
|
||||
self.template = template
|
||||
self.param = param
|
||||
|
||||
|
||||
class ResourceTemplate(BaseModel):
|
||||
"""A template for dynamically creating resources."""
|
||||
|
||||
|
|
@ -40,6 +117,8 @@ class ResourceTemplate(BaseModel):
|
|||
fn: Callable[..., Any] = Field(exclude=True)
|
||||
parameters: dict[str, Any] = Field(description="JSON schema for function parameters")
|
||||
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
|
||||
parsed_template: UriTemplate = Field(exclude=True, description="Parsed RFC 6570 template")
|
||||
security: ResourceSecurity = Field(exclude=True, description="Path-safety policy for extracted parameters")
|
||||
|
||||
@classmethod
|
||||
def from_function(
|
||||
|
|
@ -54,12 +133,20 @@ class ResourceTemplate(BaseModel):
|
|||
annotations: Annotations | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
context_kwarg: str | None = None,
|
||||
security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
|
||||
) -> ResourceTemplate:
|
||||
"""Create a template from a function."""
|
||||
"""Create a template from a function.
|
||||
|
||||
Raises:
|
||||
InvalidUriTemplate: If ``uri_template`` is malformed or uses
|
||||
unsupported RFC 6570 features.
|
||||
"""
|
||||
func_name = name or fn.__name__
|
||||
if func_name == "<lambda>":
|
||||
raise ValueError("You must provide a name for lambda functions") # pragma: no cover
|
||||
|
||||
parsed = UriTemplate.parse(uri_template)
|
||||
|
||||
# Find context parameter if it exists
|
||||
if context_kwarg is None: # pragma: no branch
|
||||
context_kwarg = find_context_parameter(fn)
|
||||
|
|
@ -86,20 +173,35 @@ class ResourceTemplate(BaseModel):
|
|||
fn=fn,
|
||||
parameters=parameters,
|
||||
context_kwarg=context_kwarg,
|
||||
parsed_template=parsed,
|
||||
security=security,
|
||||
)
|
||||
|
||||
def matches(self, uri: str) -> dict[str, Any] | None:
|
||||
"""Check if URI matches template and extract parameters.
|
||||
def matches(self, uri: str) -> dict[str, str | list[str]] | None:
|
||||
"""Check if a URI matches this template and extract parameters.
|
||||
|
||||
Extracted parameters are URL-decoded to handle percent-encoded characters.
|
||||
Delegates to :meth:`UriTemplate.match` for RFC 6570 extraction,
|
||||
then applies this template's :class:`ResourceSecurity` policy
|
||||
(path traversal, absolute paths).
|
||||
|
||||
Returns:
|
||||
Extracted parameters on success, or ``None`` if the URI
|
||||
doesn't match the template.
|
||||
|
||||
Raises:
|
||||
ResourceSecurityError: If the URI matches but an extracted
|
||||
parameter fails security validation. Raising (rather
|
||||
than returning ``None``) prevents the resource manager
|
||||
from silently falling through to a later, possibly more
|
||||
permissive, template.
|
||||
"""
|
||||
# Convert template to regex pattern
|
||||
pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
|
||||
match = re.match(f"^{pattern}$", uri)
|
||||
if match:
|
||||
# URL-decode all extracted parameter values
|
||||
return {key: unquote(value) for key, value in match.groupdict().items()}
|
||||
return None
|
||||
params = self.parsed_template.match(uri)
|
||||
if params is None:
|
||||
return None
|
||||
failed = self.security.validate(params)
|
||||
if failed is not None:
|
||||
raise ResourceSecurityError(self.uri_template, failed)
|
||||
return params
|
||||
|
||||
async def create_resource(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
|||
|
||||
import base64
|
||||
import inspect
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from typing import Any, Generic, Literal, TypeVar, overload
|
||||
|
|
@ -62,7 +61,13 @@ from mcp.server.lowlevel.server import lifespan as default_lifespan
|
|||
from mcp.server.mcpserver.context import Context
|
||||
from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError
|
||||
from mcp.server.mcpserver.prompts import Prompt, PromptManager
|
||||
from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager
|
||||
from mcp.server.mcpserver.resources import (
|
||||
DEFAULT_RESOURCE_SECURITY,
|
||||
FunctionResource,
|
||||
Resource,
|
||||
ResourceManager,
|
||||
ResourceSecurity,
|
||||
)
|
||||
from mcp.server.mcpserver.tools import Tool, ToolManager
|
||||
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
|
||||
from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger
|
||||
|
|
@ -72,6 +77,7 @@ from mcp.server.streamable_http import EventStore
|
|||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.uri_template import UriTemplate
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -150,7 +156,9 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
dependencies: list[str] | None = None,
|
||||
lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None,
|
||||
auth: AuthSettings | None = None,
|
||||
resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
|
||||
):
|
||||
self._resource_security = resource_security
|
||||
self.settings = Settings(
|
||||
debug=debug,
|
||||
log_level=log_level,
|
||||
|
|
@ -622,6 +630,7 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
icons: list[Icon] | None = None,
|
||||
annotations: Annotations | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
security: ResourceSecurity | None = None,
|
||||
) -> Callable[[_CallableT], _CallableT]:
|
||||
"""Decorator to register a function as a resource.
|
||||
|
||||
|
|
@ -631,8 +640,9 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
- bytes for binary content
|
||||
- other types will be converted to JSON
|
||||
|
||||
If the URI contains parameters (e.g. "resource://{param}") or the function
|
||||
has parameters, it will be registered as a template resource.
|
||||
If the URI contains parameters (e.g. "resource://{param}"), it is
|
||||
registered as a template resource. Otherwise it is registered as a
|
||||
static resource; function parameters on a static URI raise an error.
|
||||
|
||||
Args:
|
||||
uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
|
||||
|
|
@ -643,6 +653,9 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
icons: Optional list of icons for the resource
|
||||
annotations: Optional annotations for the resource
|
||||
meta: Optional metadata dictionary for the resource
|
||||
security: Path-safety policy for extracted template parameters.
|
||||
Defaults to the server's ``resource_security`` setting.
|
||||
Only applies to template resources.
|
||||
|
||||
Example:
|
||||
```python
|
||||
|
|
@ -664,6 +677,15 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
data = await fetch_weather(city)
|
||||
return f"Weather for {city}: {data}"
|
||||
```
|
||||
|
||||
Raises:
|
||||
InvalidUriTemplate: If ``uri`` is not a valid RFC 6570 template.
|
||||
ValueError: If URI template parameters don't match the
|
||||
function's parameters, or if a parameter bound to a
|
||||
``{?...}``/``{&...}`` query variable has no default
|
||||
(the client may omit it).
|
||||
TypeError: If the decorator is applied without being called
|
||||
(``@resource`` instead of ``@resource("uri")``).
|
||||
"""
|
||||
# Check if user passed function directly instead of calling decorator
|
||||
if callable(uri):
|
||||
|
|
@ -672,27 +694,44 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
"Did you forget to call it? Use @resource('uri') instead of @resource"
|
||||
)
|
||||
|
||||
# Parse once, early — surfaces malformed-template errors at
|
||||
# decoration time with a clear position, and gives us correct
|
||||
# variable names for all RFC 6570 operators.
|
||||
parsed = UriTemplate.parse(uri)
|
||||
uri_params = set(parsed.variable_names)
|
||||
|
||||
def decorator(fn: _CallableT) -> _CallableT:
|
||||
# Check if this should be a template
|
||||
sig = inspect.signature(fn)
|
||||
has_uri_params = "{" in uri and "}" in uri
|
||||
has_func_params = bool(sig.parameters)
|
||||
|
||||
if has_uri_params or has_func_params:
|
||||
# Check for Context parameter to exclude from validation
|
||||
context_param = find_context_parameter(fn)
|
||||
|
||||
# Validate that URI params match function params (excluding context)
|
||||
uri_params = set(re.findall(r"{(\w+)}", uri))
|
||||
# We need to remove the context_param from the resource function if
|
||||
# there is any.
|
||||
func_params = {p for p in sig.parameters.keys() if p != context_param}
|
||||
context_param = find_context_parameter(fn)
|
||||
func_params = {p for p in sig.parameters.keys() if p != context_param}
|
||||
|
||||
# Template/static is decided purely by the URI: variables
|
||||
# present means template, none means static.
|
||||
if uri_params:
|
||||
if uri_params != func_params:
|
||||
raise ValueError(
|
||||
f"Mismatch between URI parameters {uri_params} and function parameters {func_params}"
|
||||
)
|
||||
|
||||
# A {?...}/{&...} query variable is optional on the wire:
|
||||
# match() omits it from the extracted parameters when the
|
||||
# client leaves it out of the URI. The handler parameter
|
||||
# bound to it must therefore have a Python default; without
|
||||
# one, the author only finds out on the first request that
|
||||
# omits it, as an opaque internal error.
|
||||
missing_defaults = sorted(
|
||||
name
|
||||
for name in parsed.query_variable_names
|
||||
if sig.parameters[name].default is inspect.Parameter.empty
|
||||
)
|
||||
if missing_defaults:
|
||||
raise ValueError(
|
||||
f"Resource {uri!r}: query parameter(s) {missing_defaults} have no "
|
||||
f"default value. A client may omit a {{?...}}/{{&...}} query "
|
||||
f"parameter, so the matching handler parameter must declare a "
|
||||
f"default."
|
||||
)
|
||||
|
||||
# Register as template
|
||||
self._resource_manager.add_template(
|
||||
fn=fn,
|
||||
|
|
@ -703,9 +742,24 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
mime_type=mime_type,
|
||||
icons=icons,
|
||||
annotations=annotations,
|
||||
security=security if security is not None else self._resource_security,
|
||||
meta=meta,
|
||||
)
|
||||
else:
|
||||
if func_params:
|
||||
raise ValueError(
|
||||
f"Resource {uri!r} has no URI template variables, but the "
|
||||
f"handler declares parameters {func_params}. Add matching "
|
||||
f"{{...}} variables to the URI or remove the parameters."
|
||||
)
|
||||
if context_param is not None:
|
||||
raise ValueError(
|
||||
f"Resource {uri!r} has no URI template variables, but the "
|
||||
f"handler declares a Context parameter. Context injection "
|
||||
f"for static resources is not supported. "
|
||||
f"Add a template variable to the URI or remove the "
|
||||
f"Context parameter."
|
||||
)
|
||||
# Register as regular resource
|
||||
resource = FunctionResource.from_function(
|
||||
fn=fn,
|
||||
|
|
|
|||
176
src/mcp/shared/path_security.py
Normal file
176
src/mcp/shared/path_security.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""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()
|
||||
"""
|
||||
|
||||
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
|
||||
1116
src/mcp/shared/uri_template.py
Normal file
1116
src/mcp/shared/uri_template.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue