From d779414f8ad0997d37d01da2ecca4e31fd4debfb Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Fri, 17 Jul 2026 17:42:48 -0400
Subject: [PATCH] Screen templated resource parameters for path traversal by
default (#4482)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Add ResourceSecurity screening for templated resources (defaults on)
* Add tests for resource path-security screening
* Document resource path-security; fix ty in tests
* Carry child template security policy through provider mount
Preserve a mounted template's explicit ResourceSecurity (per-param
exemptions or a deliberate opt-out) through FastMCPProviderResourceTemplate.wrap
so the parent read chokepoint honours it instead of the parent default.
* Defer mcp SDK import so fastmcp.resources loads without the [mcp] extra
* Make resource path-security docs examples self-contained and runnable
* Match exempt_params under both hyphen and underscore spellings
Template placeholders like {git-ref} extract as git_ref, so an exemption
written with the natural URI-template spelling never matched.
* Docs: describe net-depth traversal rule accurately; make example runnable
The screening only rejects .. segments that escape the starting depth
(foo/../bar passes) — saying any standalone .. is rejected overstated
the guarantee. Also define DOCS_ROOT so the example runs.
---
docs/development/v4-notes/change-register.mdx | 8 +
docs/servers/resources.mdx | 82 ++-
fastmcp_slim/fastmcp/exceptions.py | 11 +
fastmcp_slim/fastmcp/resources/__init__.py | 2 +
.../fastmcp/resources/function_resource.py | 8 +
fastmcp_slim/fastmcp/resources/security.py | 162 ++++++
fastmcp_slim/fastmcp/resources/template.py | 32 ++
.../server/providers/fastmcp_provider.py | 1 +
.../local_provider/decorators/resources.py | 8 +
fastmcp_slim/fastmcp/server/server.py | 35 ++
tests/resources/test_resource_security.py | 473 ++++++++++++++++++
.../openapi/test_openapi_features.py | 13 +-
12 files changed, 824 insertions(+), 11 deletions(-)
create mode 100644 fastmcp_slim/fastmcp/resources/security.py
create mode 100644 tests/resources/test_resource_security.py
diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx
index 33fe92a98..97259fb3d 100644
--- a/docs/development/v4-notes/change-register.mdx
+++ b/docs/development/v4-notes/change-register.mdx
@@ -324,6 +324,14 @@ FastMCP keeps its own DCR redirect-URI hardening (PRs #4419, #4408) regardless o
*Verify:* recent commits `67527c1f` (block unsafe OAuth redirect schemes), `57a27992` (DNS rebinding), `cccb529f` (DCR redirect URI validation) on `main`.
+### Templated resource parameters are path-screened by default — Breaking (behavior)
+
+Every templated resource now has its extracted parameter values screened for path-traversal (`..` segments), absolute paths, and null bytes **before the handler runs** — on by default, at the server's read chokepoint, covering local and provider-sourced (mounted/proxied) templates alike. Previously these payloads reached handlers raw; a template whose parameter flowed into a filesystem path or upstream URL was exposed unless the author added their own check. A rejected read now surfaces a non-leaky "resource not found" error (`-32602`) and a debug log.
+
+The check is component-based, matching the SDK's `contains_path_traversal`: only a standalone `..` segment is traversal, so values that merely contain dots (`HEAD~3..HEAD`, `file.tar.gz`) and dotfiles (`.env`) still pass. This can break a template that legitimately accepts `..`-bearing or absolute values — exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](/servers/resources#path-security).
+
+*Verify:* `fastmcp_slim/fastmcp/resources/security.py` (`ResourceSecurity`), the screening block in `FastMCP.read_resource` (`fastmcp_slim/fastmcp/server/server.py`), and `tests/resources/test_resource_security.py`.
+
## Removed in 4.0
Deprecations that warned in 3.x are removed in 4.0. Each entry below is a hard removal — the old surface raises `TypeError` / `AttributeError` rather than warning, unless noted otherwise.
diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx
index c756c5ff9..37d8de62c 100644
--- a/docs/servers/resources.mdx
+++ b/docs/servers/resources.mdx
@@ -522,11 +522,85 @@ Wildcard parameters are useful when:
Note that like regular parameters, each wildcard parameter must still be a named parameter in your function signature, and all required function parameters must appear in the URI template.
-#### Filesystem Path Safety
+#### Path Security
-Template parameters are decoded before your function receives them. A standard `{filename}` parameter matches one URI segment before decoding, so a request like `files://a%2Fb` passes `filename="a/b"` to the handler. Treat template values as untrusted decoded URI data whenever they determine filesystem paths.
+Template parameters are extracted from the request URI and decoded before your function receives them, so a path-traversal payload like `../` or an absolute path can reach a handler that builds filesystem paths or upstream URLs. FastMCP screens every templated resource's parameter values **before the handler runs**, and this screening is **on by default**.
-Validate the final resolved path against an allowed root before reading:
+By default, a parameter value is rejected if its `..` path segments would escape the value's own starting depth, if it looks like an absolute path, or if it contains a null byte. A rejected read surfaces a clean "resource not found" error to the client and logs the reason at debug level, so the failing parameter and policy are never revealed on the wire.
+
+The traversal check is component-based and tracks net depth: `..` only counts against you when it climbs above where the value starts. `../secret`, a bare `..`, and `a/../../b` are rejected; `foo/../bar` is allowed because it never leaves the starting directory, and values that merely *contain* dots — `HEAD~3..HEAD`, `v1..v2`, `file.tar.gz`, dotfiles like `.env` — all pass. Screening runs on the decoded value, so `..%2F` is caught the same as a literal `../`. This bounds relative escapes; anchoring the *final* path inside a root directory is still your handler's job (for example with `safe_join`), since only the handler knows what the value is joined to.
+
+```python
+from pathlib import Path
+
+from fastmcp import FastMCP
+
+mcp = FastMCP(name="DocsServer")
+
+DOCS_ROOT = Path("/srv/docs")
+
+
+@mcp.resource("docs://{path*}")
+def read_doc(path: str) -> str:
+ # A request for docs://../secret is rejected before this runs.
+ return (DOCS_ROOT / path).read_text(encoding="utf-8")
+```
+
+##### Exempting parameters
+
+Some parameters legitimately carry values that look like traversal — a git ref, a version range, an opaque token. Exempt them by name with `ResourceSecurity`:
+
+```python
+from fastmcp import FastMCP
+from fastmcp.resources import ResourceSecurity
+
+mcp = FastMCP(name="DocsServer")
+
+
+@mcp.resource(
+ "git://diff/{ref}",
+ security=ResourceSecurity(exempt_params={"ref"}),
+)
+def git_diff(ref: str) -> str:
+ # ref="HEAD~3..HEAD" is allowed
+ ...
+```
+
+##### Disabling screening
+
+Pass `security=None` to turn screening off for a single component:
+
+```python
+from fastmcp import FastMCP
+
+mcp = FastMCP(name="DocsServer")
+
+
+@mcp.resource("raw://{value}", security=None)
+def raw(value: str) -> str: ...
+```
+
+Or set a server-wide default with `resource_security`, which applies to every templated resource that does not set its own `security`:
+
+```python
+from fastmcp import FastMCP
+from fastmcp.resources import ResourceSecurity
+
+# Relax one check across the whole server:
+relaxed = FastMCP(
+ name="DocsServer",
+ resource_security=ResourceSecurity(reject_absolute_paths=False),
+)
+
+# Or disable screening entirely across the server:
+unscreened = FastMCP(name="DocsServer", resource_security=None)
+```
+
+A per-component `security` always overrides the server default.
+
+
+Screening rejects the obvious injection shapes, but it does not know your filesystem root. When a parameter determines a real path, still resolve it against an allowed root and confirm containment before reading — screening and containment are complementary layers.
+
```python
from pathlib import Path
@@ -548,8 +622,6 @@ def read_doc(filename: str) -> str:
return requested_path.read_text(encoding="utf-8")
```
-Use wildcard parameters (`{path*}`) for resources whose URI shape intentionally includes slashes, and apply the same containment check before accessing the filesystem.
-
#### Query Parameters
diff --git a/fastmcp_slim/fastmcp/exceptions.py b/fastmcp_slim/fastmcp/exceptions.py
index fb6571f05..b74b50998 100644
--- a/fastmcp_slim/fastmcp/exceptions.py
+++ b/fastmcp_slim/fastmcp/exceptions.py
@@ -80,6 +80,17 @@ class DisabledError(Exception):
"""Object is disabled."""
+class ResourceSecurityError(NotFoundError):
+ """A templated resource parameter failed path-security screening.
+
+ Subclasses ``NotFoundError`` so the read handler surfaces a
+ non-leaky ``INVALID_PARAMS`` (-32602) "resource not found" error to
+ the client — a traversal attempt is indistinguishable from a request
+ for a resource that does not exist, and never reveals which parameter
+ or policy tripped.
+ """
+
+
class AuthorizationError(FastMCPError):
"""Error when authorization check fails."""
diff --git a/fastmcp_slim/fastmcp/resources/__init__.py b/fastmcp_slim/fastmcp/resources/__init__.py
index cbcfff785..cbe819c95 100644
--- a/fastmcp_slim/fastmcp/resources/__init__.py
+++ b/fastmcp_slim/fastmcp/resources/__init__.py
@@ -2,6 +2,7 @@ import sys
from .function_resource import FunctionResource, resource
from .base import Resource, ResourceContent, ResourceResult
+from .security import ResourceSecurity
from .template import ResourceTemplate
from .types import (
BinaryResource,
@@ -20,6 +21,7 @@ __all__ = [
"Resource",
"ResourceContent",
"ResourceResult",
+ "ResourceSecurity",
"ResourceTemplate",
"TextResource",
"resource",
diff --git a/fastmcp_slim/fastmcp/resources/function_resource.py b/fastmcp_slim/fastmcp/resources/function_resource.py
index 71817f835..aa71508d9 100644
--- a/fastmcp_slim/fastmcp/resources/function_resource.py
+++ b/fastmcp_slim/fastmcp/resources/function_resource.py
@@ -22,6 +22,11 @@ from pydantic import AnyUrl
from pydantic.json_schema import SkipJsonSchema
from fastmcp.resources.base import Resource, ResourceResult
+from fastmcp.resources.security import (
+ INHERIT_SECURITY,
+ InheritSecurity,
+ ResourceSecurity,
+)
from fastmcp.utilities.async_utils import (
call_sync_fn_in_threadpool,
is_coroutine_function,
@@ -64,6 +69,7 @@ class ResourceMeta:
task: bool | TaskConfig | None = None
auth: AuthCheck | list[AuthCheck] | None = None
enabled: bool = True
+ security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY
class FunctionResource(Resource):
@@ -255,6 +261,7 @@ def resource(
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
+ security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> Callable[[F], F]:
"""Standalone decorator to mark a function as an MCP resource.
@@ -284,6 +291,7 @@ def resource(
meta=meta,
task=task,
auth=auth,
+ security=security,
)
target = fn.__func__ if isinstance(fn, staticmethod | MethodType) else fn
cast(Any, target).__fastmcp__ = metadata
diff --git a/fastmcp_slim/fastmcp/resources/security.py b/fastmcp_slim/fastmcp/resources/security.py
new file mode 100644
index 000000000..bc5ba3174
--- /dev/null
+++ b/fastmcp_slim/fastmcp/resources/security.py
@@ -0,0 +1,162 @@
+"""Path-safety policy for templated resource parameters.
+
+Templated resources (`@mcp.resource("file:///{path}")`-style) extract
+parameter values straight out of the request URI and hand them to the
+resource function. When those values flow into filesystem or URI
+construction, a malicious client can smuggle path-traversal payloads
+(`../`, absolute paths, null bytes) through the template.
+
+`ResourceSecurity` screens extracted parameter values *before* the
+resource handler runs. It is applied by default to every templated
+read, mirroring the posture of the underlying MCP SDK's
+`ResourceSecurity` (traversal, absolute paths, and null bytes rejected).
+
+The screening reuses the SDK's component-based traversal check, so a
+value that merely *contains* dots (e.g. `HEAD~3..HEAD`, `v1..v2`,
+`file.tar.gz`) is not rejected — only an actual `..` path segment is.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping, Set
+from dataclasses import dataclass, field
+from functools import cache
+from typing import Any
+
+from pydantic import GetCoreSchemaHandler
+from pydantic_core import core_schema
+
+__all__ = ["ResourceSecurity"]
+
+
+@cache
+def _path_checks() -> tuple[Callable[[str], bool], Callable[[str], bool]]:
+ """Lazily load the SDK's path-safety helpers.
+
+ The screening logic lives in the `mcp` SDK, which is an optional
+ dependency of `fastmcp-slim`. Importing it at module top would make
+ `from fastmcp.resources import Resource` require the SDK, so the
+ import is deferred to the point of first use (and cached).
+ """
+ from mcp.shared.path_security import (
+ contains_path_traversal,
+ is_absolute_path,
+ )
+
+ return contains_path_traversal, is_absolute_path
+
+
+class InheritSecurity:
+ """Sentinel type: inherit the server-wide resource-security default.
+
+ Distinguishes "no per-component policy was set" (inherit whatever the
+ server configured) from an explicit ``None`` (screening disabled for
+ this component).
+ """
+
+ __slots__ = ()
+
+ def __repr__(self) -> str: # pragma: no cover - debug aid
+ return "INHERIT_SECURITY"
+
+ @classmethod
+ def __get_pydantic_core_schema__(
+ cls, source_type: Any, handler: GetCoreSchemaHandler
+ ) -> core_schema.CoreSchema:
+ # Accept the singleton sentinel as-is; it is an internal, excluded
+ # field value, so no serialization support is needed.
+ return core_schema.is_instance_schema(cls)
+
+
+INHERIT_SECURITY = InheritSecurity()
+"""Sentinel instance signalling a template should inherit the server default."""
+
+
+@dataclass(frozen=True)
+class ResourceSecurity:
+ """Security policy applied to extracted resource template parameters.
+
+ These checks run after a URI has matched a template and its
+ parameter values have been extracted and percent-decoded. They catch
+ path-traversal and absolute-path injection regardless of how the
+ value was encoded in the URI (literal, `%2F`, `%5C`, `%2E%2E`).
+
+ All checks default on. Screen a value like `HEAD~3..HEAD` (dots
+ inside a single segment) passes — only a standalone `..` segment is
+ treated as traversal.
+
+ Example:
+ Opt a parameter out of screening (e.g. a git ref that may
+ legitimately contain `..`):
+
+ ```python
+ from fastmcp.resources import ResourceSecurity
+
+ @mcp.resource(
+ "git://diff/{ref}",
+ security=ResourceSecurity(exempt_params={"ref"}),
+ )
+ def git_diff(ref: 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)
+ """Parameter names to skip all checks for. Hyphenated URI-template
+ spellings are accepted: `{git-ref}` is extracted as `git_ref`, and an
+ exemption written either way matches it."""
+
+ def _exempt(self, name: str) -> bool:
+ """True if `name` is exempted under either its extracted or its
+ URI-template spelling (hyphens normalize to underscores on
+ extraction, so `exempt_params={"git-ref"}` must match `git_ref`)."""
+ if name in self.exempt_params:
+ return True
+ return any(exempt.replace("-", "_") == name for exempt in self.exempt_params)
+
+ def validate(self, params: Mapping[str, object]) -> str | None:
+ """Check all parameter values against the configured policy.
+
+ String values (and lists of strings, from wildcard `{path*}`
+ parameters that span multiple segments) are screened; non-string
+ values are ignored, since traversal is a string-path concern.
+
+ Args:
+ params: Extracted template parameters.
+
+ Returns:
+ The name of the first parameter that fails, or `None` if all
+ values pass.
+ """
+ contains_path_traversal, is_absolute_path = _path_checks()
+ for name, value in params.items():
+ if self._exempt(name):
+ continue
+ if isinstance(value, str):
+ candidates = [value]
+ elif isinstance(value, (list, tuple)):
+ candidates = [v for v in value if isinstance(v, str)]
+ else:
+ continue
+ for candidate in candidates:
+ if self.reject_null_bytes and "\0" in candidate:
+ return name
+ if self.reject_path_traversal and contains_path_traversal(candidate):
+ return name
+ if self.reject_absolute_paths and is_absolute_path(candidate):
+ return name
+ return None
+
+
+DEFAULT_RESOURCE_SECURITY = ResourceSecurity()
+"""Secure-by-default policy: traversal, absolute paths, and null bytes rejected."""
diff --git a/fastmcp_slim/fastmcp/resources/template.py b/fastmcp_slim/fastmcp/resources/template.py
index 7cd7b244e..00cceaeec 100644
--- a/fastmcp_slim/fastmcp/resources/template.py
+++ b/fastmcp_slim/fastmcp/resources/template.py
@@ -24,6 +24,11 @@ from pydantic import (
)
from fastmcp.resources.base import Resource, ResourceResult
+from fastmcp.resources.security import (
+ INHERIT_SECURITY,
+ InheritSecurity,
+ ResourceSecurity,
+)
from fastmcp.utilities.authorization import AuthCheck
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
@@ -186,6 +191,29 @@ class ResourceTemplate(FastMCPComponent):
description="Authorization checks for this resource template",
exclude=True,
)
+ security: SkipJsonSchema[ResourceSecurity | None | InheritSecurity] = Field(
+ default=INHERIT_SECURITY,
+ description=(
+ "Path-safety policy for extracted parameters. INHERIT_SECURITY "
+ "(default) inherits the server-wide default; None disables "
+ "screening; a ResourceSecurity instance applies that explicit "
+ "policy."
+ ),
+ exclude=True,
+ )
+
+ def resolve_security(
+ self, server_default: ResourceSecurity | None
+ ) -> ResourceSecurity | None:
+ """Resolve the effective security policy for this template.
+
+ A per-component ``security`` overrides the server default.
+ ``INHERIT_SECURITY`` (the field default) inherits ``server_default``;
+ an explicit ``None`` disables screening for this template.
+ """
+ if isinstance(self.security, InheritSecurity):
+ return server_default
+ return self.security
def __repr__(self) -> str:
return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
@@ -205,6 +233,7 @@ class ResourceTemplate(FastMCPComponent):
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
+ security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> FunctionResourceTemplate:
return FunctionResourceTemplate.from_function(
fn=fn,
@@ -220,6 +249,7 @@ class ResourceTemplate(FastMCPComponent):
meta=meta,
task=task,
auth=auth,
+ security=security,
)
@field_validator("mime_type", mode="before")
@@ -544,6 +574,7 @@ class FunctionResourceTemplate(ResourceTemplate):
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
+ security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> FunctionResourceTemplate:
"""Create a template from a function."""
@@ -683,4 +714,5 @@ class FunctionResourceTemplate(ResourceTemplate):
meta=meta,
task_config=task_config,
auth=auth,
+ security=security,
)
diff --git a/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py b/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py
index 14dbbbae0..27787d2ef 100644
--- a/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py
+++ b/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py
@@ -363,6 +363,7 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
meta=template.get_meta(),
title=template.title,
icons=template.icons,
+ security=template.security,
)
async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
diff --git a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py
index c9cb475ac..75d23a967 100644
--- a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py
+++ b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py
@@ -14,6 +14,11 @@ import mcp_types
from mcp_types import Annotations
from fastmcp.resources.base import Resource
+from fastmcp.resources.security import (
+ INHERIT_SECURITY,
+ InheritSecurity,
+ ResourceSecurity,
+)
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
@@ -70,6 +75,7 @@ class ResourceDecoratorMixin:
meta=meta.meta,
task=resolved_task,
auth=meta.auth,
+ security=meta.security,
)
else:
resource = Resource.from_function(
@@ -119,6 +125,7 @@ class ResourceDecoratorMixin:
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
+ security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> Callable[[F], F]:
"""Decorator to register a function as a resource.
@@ -202,6 +209,7 @@ class ResourceDecoratorMixin:
task=task,
auth=auth,
enabled=enabled,
+ security=security,
)
target = fn.__func__ if hasattr(fn, "__func__") else fn
target.__fastmcp__ = metadata # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py
index aea64f111..e940ac2a2 100644
--- a/fastmcp_slim/fastmcp/server/server.py
+++ b/fastmcp_slim/fastmcp/server/server.py
@@ -49,6 +49,7 @@ from fastmcp.exceptions import (
NotFoundError,
PromptError,
ResourceError,
+ ResourceSecurityError,
ToolError,
ValidationError,
)
@@ -57,6 +58,12 @@ from fastmcp.prompts import Prompt
from fastmcp.prompts.base import PromptResult
from fastmcp.prompts.function_prompt import FunctionPrompt
from fastmcp.resources.base import Resource, ResourceResult
+from fastmcp.resources.security import (
+ DEFAULT_RESOURCE_SECURITY,
+ INHERIT_SECURITY,
+ InheritSecurity,
+ ResourceSecurity,
+)
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.auth import AuthCheck, AuthContext, AuthProvider, run_auth_checks
from fastmcp.server.caching import build_cache_hints
@@ -330,6 +337,7 @@ class FastMCP(
dereference_schemas: bool = True,
strict_input_validation: bool | None = None,
list_page_size: int | None = None,
+ resource_security: ResourceSecurity | None = DEFAULT_RESOURCE_SECURITY,
cache_ttl: int | None = None,
cache_scope: Literal["public", "private"] | None = None,
tasks: bool | None = None,
@@ -390,6 +398,13 @@ class FastMCP(
raise ValueError("list_page_size must be a positive integer")
self._list_page_size: int | None = list_page_size
+ # Server-wide default path-security policy for templated resources.
+ # Applied before the handler runs to every templated read whose
+ # component does not override it. DEFAULT_RESOURCE_SECURITY screens
+ # traversal, absolute paths, and null bytes; None disables screening
+ # server-wide.
+ self._resource_security: ResourceSecurity | None = resource_security
+
# Server-level SEP-2549 cache hints, applied uniformly to every
# SDK-cacheable result by the low-level server's runner (raises on
# invalid ttl/scope).
@@ -1490,6 +1505,24 @@ class FastMCP(
span.set_attributes(template.get_span_attributes())
params = template.matches(uri)
assert params is not None
+
+ # Path-security screening: reject traversal / absolute-path /
+ # null-byte payloads in extracted parameter values BEFORE the
+ # handler runs. This is the single chokepoint for every
+ # templated read (local decorator and provider-sourced), so
+ # enforcement lives here rather than in any decorator.
+ security = template.resolve_security(self._resource_security)
+ if security is not None:
+ failed = security.validate(params)
+ if failed is not None:
+ logger.debug(
+ "Rejected resource %r: parameter %r failed "
+ "path-security screening",
+ uri,
+ failed,
+ )
+ raise ResourceSecurityError(f"Unknown resource: {uri!r}")
+
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=template.key)
try:
@@ -1824,6 +1857,7 @@ class FastMCP(
app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
+ security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> Callable[[F], F]:
"""Decorator to register a function as a resource.
@@ -1923,6 +1957,7 @@ class FastMCP(
meta=meta,
task=task if task is not None else self._support_tasks_by_default,
auth=auth,
+ security=security,
)
return inner_decorator
diff --git a/tests/resources/test_resource_security.py b/tests/resources/test_resource_security.py
new file mode 100644
index 000000000..f530d63c9
--- /dev/null
+++ b/tests/resources/test_resource_security.py
@@ -0,0 +1,473 @@
+"""Tests for path-security screening of templated resource parameters.
+
+Templated resources extract parameter values from request URIs and hand
+them to the handler. `ResourceSecurity` screens those values (traversal,
+absolute paths, null bytes) before the handler runs, defaults-on, at the
+server's read chokepoint.
+
+The screening is applied to the *raw* URI string reaching the server
+(`FastMCP.read_resource(str)`), which is the path the JSON-RPC handler and
+internal callers use. Over the in-memory `Client`, URIs are wrapped in
+`AnyUrl`, which independently normalises many `..` payloads away before
+they reach the server — a separate layer of defense.
+"""
+
+import subprocess
+import sys
+import textwrap
+
+import pytest
+
+from fastmcp import Client, FastMCP
+from fastmcp.exceptions import ResourceSecurityError
+from fastmcp.resources.security import (
+ DEFAULT_RESOURCE_SECURITY,
+ INHERIT_SECURITY,
+ ResourceSecurity,
+)
+from fastmcp.resources.template import ResourceTemplate
+
+# ---------------------------------------------------------------------------
+# ResourceSecurity model (unit)
+# ---------------------------------------------------------------------------
+
+
+class TestResourceSecurityModel:
+ @pytest.mark.parametrize(
+ "value",
+ [
+ "../etc/passwd",
+ "..",
+ "a/../../b",
+ "nested/../../outside",
+ ],
+ )
+ def test_rejects_traversal(self, value: str):
+ assert ResourceSecurity().validate({"path": value}) == "path"
+
+ @pytest.mark.parametrize(
+ "value",
+ [
+ "/etc/passwd",
+ "/absolute/injection",
+ "C:\\Windows",
+ "C:relative",
+ "\\\\server\\share",
+ ],
+ )
+ def test_rejects_absolute(self, value: str):
+ assert ResourceSecurity().validate({"path": value}) == "path"
+
+ @pytest.mark.parametrize(
+ "value",
+ [
+ "a\x00b",
+ "good\x00/../../../etc/passwd",
+ ],
+ )
+ def test_rejects_null_bytes(self, value: str):
+ assert ResourceSecurity().validate({"path": value}) == "path"
+
+ @pytest.mark.parametrize(
+ "value",
+ [
+ "HEAD~3..HEAD",
+ "v1..v2",
+ "a.b.c",
+ "file.tar.gz",
+ "1.0..2.0",
+ ".env",
+ ".git/config",
+ "...",
+ "docs/readme.txt",
+ "foo/../bar", # net depth stays >= 0 -> not an escape (SDK semantics)
+ "café/naïve",
+ ],
+ )
+ def test_allows_safe_values(self, value: str):
+ """Dots inside a segment, benign relative paths, and dotfiles pass.
+
+ This mirrors the SDK's component-based `contains_path_traversal`:
+ only a standalone `..` segment counts as traversal. A leading-dot
+ single segment (`.env`) is an ordinary name, not traversal, and
+ passes default screening — filesystem exposure of such names is the
+ handler's concern (e.g. via `safe_join` to a root), not this check.
+ """
+ assert ResourceSecurity().validate({"path": value}) is None
+
+ def test_exempt_params_skipped(self):
+ security = ResourceSecurity(exempt_params={"ref"})
+ assert security.validate({"ref": "../anything"}) is None
+ # A non-exempt param is still screened.
+ assert security.validate({"path": "../x", "ref": "../y"}) == "path"
+
+ def test_hyphenated_exemption_matches_normalized_param(self):
+ """`{git-ref}` extracts as `git_ref`; an exemption written with the
+ URI-template (hyphen) spelling must still match it."""
+ security = ResourceSecurity(exempt_params={"git-ref"})
+ assert security.validate({"git_ref": "HEAD~3../x"}) is None
+ assert security.validate({"git_ref": "../x"}) is None
+ # The underscore spelling keeps working too.
+ assert (
+ ResourceSecurity(exempt_params={"git_ref"}).validate({"git_ref": "../x"})
+ is None
+ )
+ # An unrelated hyphenated exemption does not leak onto other params.
+ assert security.validate({"path": "../x"}) == "path"
+
+ def test_wildcard_segments_screened_element_wise(self):
+ """List values (from wildcard {path*}) are screened per element."""
+ assert ResourceSecurity().validate({"path": ["a", "..", "b"]}) == "path"
+ assert ResourceSecurity().validate({"path": ["a", "b", "c"]}) is None
+
+ def test_non_string_values_ignored(self):
+ assert ResourceSecurity().validate({"n": 5, "flag": True}) is None
+
+ def test_individual_checks_toggleable(self):
+ no_traversal = ResourceSecurity(reject_path_traversal=False)
+ assert no_traversal.validate({"path": "../x"}) is None
+ # but absolute still rejected
+ assert no_traversal.validate({"path": "/etc/passwd"}) == "path"
+
+ def test_returns_first_failing_param_name(self):
+ # dict order preserved; first failing name returned
+ result = ResourceSecurity().validate({"safe": "ok", "bad": ".."})
+ assert result == "bad"
+
+
+# ---------------------------------------------------------------------------
+# Bare-slim import: the module must not eagerly require the optional SDK
+# ---------------------------------------------------------------------------
+
+
+class TestBareSlimImport:
+ """`fastmcp-slim` installs the `mcp` SDK only under the `[mcp]` extra.
+
+ The path-safety helpers live in `mcp.shared.path_security`, so importing
+ them at module top would make `from fastmcp.resources import Resource`
+ require the SDK — regressing a previously dependency-free import path.
+ The import must be deferred to the point of actual screening.
+ """
+
+ def test_resources_import_without_sdk(self):
+ code = textwrap.dedent(
+ """
+ import sys, builtins
+ _real_import = builtins.__import__
+
+ def blocked_import(name, *args, **kwargs):
+ if name == "mcp" or name.startswith("mcp."):
+ raise ModuleNotFoundError(f"No module named '{name}'")
+ return _real_import(name, *args, **kwargs)
+
+ builtins.__import__ = blocked_import
+ for mod in list(sys.modules):
+ if mod == "mcp" or mod.startswith("mcp."):
+ del sys.modules[mod]
+
+ from fastmcp.resources import Resource, ResourceSecurity # noqa: F401
+ import fastmcp.resources # noqa: F401
+ print("OK")
+ """
+ )
+ result = subprocess.run(
+ [sys.executable, "-c", code],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, result.stderr
+ assert "OK" in result.stdout
+
+ def test_screening_still_works_with_sdk(self):
+ # With the SDK present (the normal test environment), the deferred
+ # import resolves and screening behaves exactly as before.
+ assert ResourceSecurity().validate({"path": "../etc/passwd"}) == "path"
+ assert ResourceSecurity().validate({"path": "/etc/passwd"}) == "path"
+ assert ResourceSecurity().validate({"path": "safe/file.txt"}) is None
+
+
+# ---------------------------------------------------------------------------
+# Enforcement at the server chokepoint (raw-string reads)
+# ---------------------------------------------------------------------------
+
+
+class TestChokepointEnforcement:
+ @pytest.fixture
+ def server(self) -> FastMCP:
+ mcp = FastMCP("test")
+
+ @mcp.resource("file:///{path*}")
+ def read_file(path: str) -> str:
+ return f"content:{path}"
+
+ return mcp
+
+ @pytest.mark.parametrize(
+ "uri",
+ [
+ "file:///../etc/passwd",
+ "file:///a/../../b",
+ "file:////etc/passwd", # -> path param '/etc/passwd' (absolute)
+ "file:///a\x00b",
+ ],
+ )
+ async def test_traversal_rejected_by_default(self, server: FastMCP, uri: str):
+ with pytest.raises(ResourceSecurityError):
+ await server.read_resource(uri)
+
+ @pytest.mark.parametrize(
+ "uri",
+ [
+ "file:///docs/readme.txt",
+ "file:///HEAD~3..HEAD",
+ "file:///v1..v2",
+ "file:///file.tar.gz",
+ "file:///.env",
+ ],
+ )
+ async def test_safe_uris_pass_by_default(self, server: FastMCP, uri: str):
+ result = await server.read_resource(uri)
+ content = result.contents[0].content
+ assert isinstance(content, str)
+ assert content.startswith("content:")
+
+
+class TestServerDefaultConfiguration:
+ async def test_server_default_disabled(self):
+ mcp = FastMCP("test", resource_security=None)
+
+ @mcp.resource("file:///{path*}")
+ def read_file(path: str) -> str:
+ return f"content:{path}"
+
+ # Traversal passes when server-wide screening is disabled.
+ result = await mcp.read_resource("file:///../etc/passwd")
+ assert result.contents[0].content == "content:../etc/passwd"
+
+ async def test_server_default_custom_exemption(self):
+ mcp = FastMCP(
+ "test",
+ resource_security=ResourceSecurity(exempt_params={"path"}),
+ )
+
+ @mcp.resource("file:///{path*}")
+ def read_file(path: str) -> str:
+ return f"content:{path}"
+
+ result = await mcp.read_resource("file:///../etc/passwd")
+ assert result.contents[0].content == "content:../etc/passwd"
+
+ async def test_server_default_applies_to_all_templates(self):
+ """A single server default screens every templated resource."""
+ mcp = FastMCP("test")
+
+ @mcp.resource("a://{path*}")
+ def read_a(path: str) -> str:
+ return path
+
+ @mcp.resource("b://{path*}")
+ def read_b(path: str) -> str:
+ return path
+
+ for scheme in ("a", "b"):
+ with pytest.raises(ResourceSecurityError):
+ await mcp.read_resource(f"{scheme}://../escape")
+
+
+class TestPerComponentOverride:
+ async def test_component_disable_overrides_server_default(self):
+ mcp = FastMCP("test") # default: screening on
+
+ @mcp.resource("git://diff/{ref}", security=None)
+ def git_diff(ref: str) -> str:
+ return f"diff:{ref}"
+
+ # '..' in the ref is allowed because this component disabled screening.
+ result = await mcp.read_resource("git://diff/HEAD~3..HEAD")
+ assert result.contents[0].content == "diff:HEAD~3..HEAD"
+
+ async def test_component_exemption_overrides_server_default(self):
+ mcp = FastMCP("test")
+
+ @mcp.resource(
+ "git://diff/{ref}",
+ security=ResourceSecurity(exempt_params={"ref"}),
+ )
+ def git_diff(ref: str) -> str:
+ return f"diff:{ref}"
+
+ result = await mcp.read_resource("git://diff/..")
+ assert result.contents[0].content == "diff:.."
+
+ async def test_component_enables_over_disabled_server_default(self):
+ """A per-component policy overrides a server default of None."""
+ mcp = FastMCP("test", resource_security=None)
+
+ @mcp.resource("file:///{path*}", security=ResourceSecurity())
+ def read_file(path: str) -> str:
+ return path
+
+ with pytest.raises(ResourceSecurityError):
+ await mcp.read_resource("file:///../etc/passwd")
+
+ def test_inherit_default_on_template(self):
+ def read_file(path: str) -> str:
+ return path
+
+ template = ResourceTemplate.from_function(read_file, "file:///{path*}")
+ assert template.security is INHERIT_SECURITY
+ assert template.resolve_security(DEFAULT_RESOURCE_SECURITY) is (
+ DEFAULT_RESOURCE_SECURITY
+ )
+
+ def test_explicit_none_disables(self):
+ def read_file(path: str) -> str:
+ return path
+
+ template = ResourceTemplate.from_function(
+ read_file, "file:///{path*}", security=None
+ )
+ assert template.resolve_security(DEFAULT_RESOURCE_SECURITY) is None
+
+
+# ---------------------------------------------------------------------------
+# End-to-end through the in-memory Client
+# ---------------------------------------------------------------------------
+
+
+class TestEndToEndClient:
+ async def test_traversal_read_gets_clean_not_found(self):
+ """A traversal attempt over the wire surfaces a non-leaky error.
+
+ `resource://..` survives `AnyUrl` normalisation (the `..` sits in
+ the authority, not the path), so it reaches the server chokepoint
+ and is rejected. The client sees a generic "resource not found"
+ error that never reveals the screening reason.
+ """
+ mcp = FastMCP("test")
+
+ @mcp.resource("resource://{path*}")
+ def read(path: str) -> str:
+ return path
+
+ async with Client(mcp) as client:
+ with pytest.raises(Exception) as exc_info:
+ await client.read_resource("resource://..")
+
+ message = str(exc_info.value)
+ assert "not found" in message.lower()
+ # Non-leaky: the error must not name the failing parameter or policy.
+ assert "path" not in message.lower()
+ assert "security" not in message.lower()
+
+ async def test_legit_read_succeeds(self):
+ mcp = FastMCP("test")
+
+ @mcp.resource("file:///{path*}")
+ def read(path: str) -> str:
+ return f"content:{path}"
+
+ async with Client(mcp) as client:
+ result = await client.read_resource("file:///docs/readme.txt")
+
+ assert result[0].text == "content:docs/readme.txt"
+
+
+# ---------------------------------------------------------------------------
+# Provider-sourced templates (mounted servers)
+# ---------------------------------------------------------------------------
+
+
+class TestProviderSourcedTemplates:
+ """Templates surfaced by a provider route through the same chokepoint.
+
+ Enforcement lives at the server read chokepoint, not in the decorator,
+ so a mounted server's templates inherit the *parent* server's default
+ policy and are screened before the request is delegated.
+ """
+
+ async def test_mounted_template_screened_by_parent_default(self):
+ child = FastMCP("child")
+
+ @child.resource("file:///{path*}")
+ def read_file(path: str) -> str:
+ return f"child:{path}"
+
+ parent = FastMCP("parent")
+ parent.mount(child)
+
+ with pytest.raises(ResourceSecurityError):
+ await parent.read_resource("file:///../escape")
+
+ async def test_mounted_template_safe_read_succeeds(self):
+ child = FastMCP("child")
+
+ @child.resource("file:///{path*}")
+ def read_file(path: str) -> str:
+ return f"child:{path}"
+
+ parent = FastMCP("parent")
+ parent.mount(child)
+
+ result = await parent.read_resource("file:///docs/ok.txt")
+ assert result.contents[0].content == "child:docs/ok.txt"
+
+ async def test_parent_default_screens_even_if_child_disabled(self):
+ """The parent's policy applies even when the child disabled its own.
+
+ Screening runs at each server's chokepoint. A traversal is caught by
+ the parent before delegation regardless of the child's configuration.
+ """
+ child = FastMCP("child", resource_security=None)
+
+ @child.resource("file:///{path*}")
+ def read_file(path: str) -> str:
+ return f"child:{path}"
+
+ parent = FastMCP("parent") # default screening on
+ parent.mount(child)
+
+ with pytest.raises(ResourceSecurityError):
+ await parent.read_resource("file:///../escape")
+
+ async def test_mounted_template_exempt_param_preserved(self):
+ """A child template's explicit per-param exemption survives the mount.
+
+ The child opts one parameter out of screening. That policy must be
+ carried through the provider-wrapped template so the parent's read
+ chokepoint honours it instead of falling back to the parent default.
+ """
+ child = FastMCP("child")
+
+ @child.resource(
+ "git://diff/{ref}/{path*}",
+ security=ResourceSecurity(exempt_params={"ref"}),
+ )
+ def git_diff(ref: str, path: str) -> str:
+ return f"child:{ref}:{path}"
+
+ parent = FastMCP("parent") # default screening on
+ parent.mount(child)
+
+ # `..` in the exempt `ref` param is allowed through the mount.
+ result = await parent.read_resource("git://diff/../safe")
+ assert result.contents[0].content == "child:..:safe"
+
+ # A traversal on the NON-exempt `path` param is still rejected.
+ with pytest.raises(ResourceSecurityError):
+ await parent.read_resource("git://diff/main/../escape")
+
+ async def test_mounted_template_disabled_security_preserved(self):
+ """A child template that explicitly disables screening keeps that
+ opt-out through the mount rather than inheriting the parent default."""
+ child = FastMCP("child")
+
+ @child.resource("git://raw/{path*}", security=None)
+ def read_raw(path: str) -> str:
+ return f"child:{path}"
+
+ parent = FastMCP("parent") # default screening on
+ parent.mount(child)
+
+ result = await parent.read_resource("git://raw/../escape")
+ assert result.contents[0].content == "child:../escape"
diff --git a/tests/server/providers/openapi/test_openapi_features.py b/tests/server/providers/openapi/test_openapi_features.py
index 95c7f1a82..2c94e4386 100644
--- a/tests/server/providers/openapi/test_openapi_features.py
+++ b/tests/server/providers/openapi/test_openapi_features.py
@@ -762,14 +762,15 @@ class TestResourceTemplateRequestBuilding:
mcp.add_provider(provider)
async with Client(mcp) as mcp_client:
- await mcp_client.read_resource(
- "resource://get_user/..%2F..%2Fadmin%2Fsecret"
- )
+ # Reserved characters (encoded slash + space) must be
+ # re-encoded when building the outbound URL. A traversal
+ # payload (`..%2F...`) would be rejected by the default
+ # resource-security screening, so use a benign value that
+ # still exercises reserved-character encoding.
+ await mcp_client.read_resource("resource://get_user/a%2Fb%20c")
assert seen_urls == [
- httpx.URL(
- "https://api.example.com/api/v1/users/%2E%2E%2F%2E%2E%2Fadmin%2Fsecret"
- )
+ httpx.URL("https://api.example.com/api/v1/users/a%2Fb%20c")
]
async def test_resource_template_ignores_unmatched_query_string(