Skip to content

Commit 6e55991

Browse files
committed
fix: raise ResourceSecurityError instead of falling through on rejection
ResourceTemplate.matches() previously returned None for both "URI doesn't match this template" and "URI matches but fails security validation". ResourceManager.get_resource iterates templates and uses the first non-None result, so a strict template's security rejection would silently fall through to a later, possibly permissive, template. Registration order became security-critical without documentation. matches() now raises ResourceSecurityError on security failure, halting template iteration at the first rejection. The error carries the template string and the offending parameter name. ResourceSecurity.validate() now returns the name of the first failing parameter (or None if all pass) rather than a bool, so the error can identify which parameter was rejected.
1 parent 8fb3d6f commit 6e55991

File tree

3 files changed

+73
-18
lines changed

3 files changed

+73
-18
lines changed

src/mcp/server/mcpserver/resources/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .templates import (
44
DEFAULT_RESOURCE_SECURITY,
55
ResourceSecurity,
6+
ResourceSecurityError,
67
ResourceTemplate,
78
)
89
from .types import (
@@ -25,5 +26,6 @@
2526
"ResourceTemplate",
2627
"ResourceManager",
2728
"ResourceSecurity",
29+
"ResourceSecurityError",
2830
"DEFAULT_RESOURCE_SECURITY",
2931
]

src/mcp/server/mcpserver/resources/templates.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,34 +54,49 @@ def git_diff(range: str) -> str: ...
5454
exempt_params: Set[str] = field(default_factory=frozenset[str])
5555
"""Parameter names to skip all checks for."""
5656

57-
def validate(self, params: Mapping[str, str | list[str]]) -> bool:
57+
def validate(self, params: Mapping[str, str | list[str]]) -> str | None:
5858
"""Check all parameter values against the configured policy.
5959
6060
Args:
6161
params: Extracted template parameters. List values (from
6262
explode variables) are checked element-wise.
6363
6464
Returns:
65-
``True`` if all values pass; ``False`` on first violation.
65+
The name of the first parameter that fails, or ``None`` if
66+
all values pass.
6667
"""
6768
for name, value in params.items():
6869
if name in self.exempt_params:
6970
continue
7071
values = value if isinstance(value, list) else [value]
7172
for v in values:
7273
if self.reject_null_bytes and "\0" in v:
73-
return False
74+
return name
7475
if self.reject_path_traversal and contains_path_traversal(v):
75-
return False
76+
return name
7677
if self.reject_absolute_paths and is_absolute_path(v):
77-
return False
78-
return True
78+
return name
79+
return None
7980

8081

8182
DEFAULT_RESOURCE_SECURITY = ResourceSecurity()
8283
"""Secure-by-default policy: traversal and absolute paths rejected."""
8384

8485

86+
class ResourceSecurityError(ValueError):
87+
"""Raised when an extracted parameter fails :class:`ResourceSecurity` checks.
88+
89+
Distinct from a simple ``None`` non-match so that template
90+
iteration can stop at the first security rejection rather than
91+
falling through to a later, possibly more permissive, template.
92+
"""
93+
94+
def __init__(self, template: str, param: str) -> None:
95+
super().__init__(f"Parameter {param!r} of template {template!r} failed security validation")
96+
self.template = template
97+
self.param = param
98+
99+
85100
class ResourceTemplate(BaseModel):
86101
"""A template for dynamically creating resources."""
87102

@@ -165,13 +180,21 @@ def matches(self, uri: str) -> dict[str, str | list[str]] | None:
165180
166181
Returns:
167182
Extracted parameters on success, or ``None`` if the URI
168-
doesn't match or a parameter fails security validation.
183+
doesn't match the template.
184+
185+
Raises:
186+
ResourceSecurityError: If the URI matches but an extracted
187+
parameter fails security validation. Raising (rather
188+
than returning ``None``) prevents the resource manager
189+
from silently falling through to a later, possibly more
190+
permissive, template.
169191
"""
170192
params = self.parsed_template.match(uri)
171193
if params is None:
172194
return None
173-
if not self.security.validate(params):
174-
return None
195+
failed = self.security.validate(params)
196+
if failed is not None:
197+
raise ResourceSecurityError(self.uri_template, failed)
175198
return params
176199

177200
async def create_resource(

tests/server/mcpserver/resources/test_resource_template.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from mcp.server.mcpserver.resources.templates import (
1010
DEFAULT_RESOURCE_SECURITY,
1111
ResourceSecurity,
12+
ResourceSecurityError,
1213
)
1314
from mcp.types import Annotations
1415

@@ -30,23 +31,27 @@ def test_matches_rejects_encoded_slash_traversal():
3031
# %2F decodes to / in UriTemplate.match(), giving "../../etc/passwd".
3132
# ResourceSecurity's traversal check then rejects the '..' components.
3233
t = _make("file://docs/{name}")
33-
assert t.matches("file://docs/..%2F..%2Fetc%2Fpasswd") is None
34+
with pytest.raises(ResourceSecurityError, match="'name'"):
35+
t.matches("file://docs/..%2F..%2Fetc%2Fpasswd")
3436

3537

3638
def test_matches_rejects_path_traversal_by_default():
3739
t = _make("file://docs/{name}")
38-
assert t.matches("file://docs/..") is None
40+
with pytest.raises(ResourceSecurityError):
41+
t.matches("file://docs/..")
3942

4043

4144
def test_matches_rejects_path_traversal_in_reserved_var():
4245
# Even {+path} gets the traversal check — it's semantic, not structural
4346
t = _make("file://docs/{+path}")
44-
assert t.matches("file://docs/../../etc/passwd") is None
47+
with pytest.raises(ResourceSecurityError):
48+
t.matches("file://docs/../../etc/passwd")
4549

4650

4751
def test_matches_rejects_absolute_path():
4852
t = _make("file://docs/{+path}")
49-
assert t.matches("file://docs//etc/passwd") is None
53+
with pytest.raises(ResourceSecurityError):
54+
t.matches("file://docs//etc/passwd")
5055

5156

5257
def test_matches_allows_dotdot_as_substring():
@@ -71,9 +76,11 @@ def test_matches_rejects_null_byte_by_default():
7176
# %00 decodes to \x00 which defeats string comparisons
7277
# ("..\x00" != "..") and can truncate in C extensions.
7378
t = _make("file://docs/{name}")
74-
assert t.matches("file://docs/key%00.txt") is None
79+
with pytest.raises(ResourceSecurityError):
80+
t.matches("file://docs/key%00.txt")
7581
# Null byte also defeats the traversal check's component comparison
76-
assert t.matches("file://docs/..%00%2Fsecret") is None
82+
with pytest.raises(ResourceSecurityError):
83+
t.matches("file://docs/..%00%2Fsecret")
7784

7885

7986
def test_matches_null_byte_check_can_be_disabled():
@@ -82,24 +89,47 @@ def test_matches_null_byte_check_can_be_disabled():
8289
assert t.matches("file://docs/key%00.txt") == {"name": "key\x00.txt"}
8390

8491

92+
def test_security_rejection_does_not_fall_through_to_next_template():
93+
# A strict template's security rejection must halt iteration, not
94+
# fall through to a later permissive template. Previously matches()
95+
# returned None for both "no match" and "security failed", making
96+
# registration order security-critical.
97+
strict = _make("file://docs/{name}")
98+
lax = _make(
99+
"file://docs/{+path}",
100+
security=ResourceSecurity(exempt_params={"path"}),
101+
)
102+
uri = "file://docs/..%2Fsecrets"
103+
# Strict matches structurally then fails security -> raises.
104+
with pytest.raises(ResourceSecurityError) as exc:
105+
strict.matches(uri)
106+
assert exc.value.param == "name"
107+
# If this raised, the resource manager never reaches the lax
108+
# template. Verify the lax template WOULD have accepted it.
109+
assert lax.matches(uri) == {"path": "../secrets"}
110+
111+
85112
def test_matches_explode_checks_each_segment():
86113
t = _make("api{/parts*}")
87114
assert t.matches("api/a/b/c") == {"parts": ["a", "b", "c"]}
88115
# Any segment with traversal rejects the whole match
89-
assert t.matches("api/a/../c") is None
116+
with pytest.raises(ResourceSecurityError):
117+
t.matches("api/a/../c")
90118

91119

92120
def test_matches_encoded_backslash_caught_by_traversal_check():
93121
# %5C decodes to '\\'. The traversal check normalizes '\\' to '/'
94122
# and catches the '..' components.
95123
t = _make("file://docs/{name}")
96-
assert t.matches("file://docs/..%5C..%5Csecret") is None
124+
with pytest.raises(ResourceSecurityError):
125+
t.matches("file://docs/..%5C..%5Csecret")
97126

98127

99128
def test_matches_encoded_dots_caught_by_traversal_check():
100129
# %2E%2E decodes to '..' which the traversal check rejects.
101130
t = _make("file://docs/{name}")
102-
assert t.matches("file://docs/%2E%2E") is None
131+
with pytest.raises(ResourceSecurityError):
132+
t.matches("file://docs/%2E%2E")
103133

104134

105135
def test_matches_mixed_encoded_and_literal_slash():

0 commit comments

Comments
 (0)