Skip to content

Commit 5b04431

Browse files
committed
fix(skills): unquote percent-encoded dot-segments during URI traversal validation
1 parent 4b5fe5a commit 5b04431

2 files changed

Lines changed: 31 additions & 6 deletions

File tree

‎src/mcp/shared/skills.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import re
1111
from typing import Annotated, Any, Literal
12-
from urllib.parse import urlsplit
12+
from urllib.parse import unquote, urlsplit
1313

1414
from mcp_types import CacheableResult, PaginatedRequestParams, PaginatedResult, Request, RequestParams, Resource
1515
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator
@@ -250,7 +250,8 @@ def _validate_resource_uri_in_skill(skill_uri: str, resource_uri: str) -> None:
250250
root = skill_parts.path[: -len("/SKILL.md")]
251251
if resource_parts.path != skill_parts.path and not resource_parts.path.startswith(root + "/"):
252252
raise ValueError(f"resource URI {resource_uri!r} is outside the skill root {skill_uri!r}")
253-
if any(segment in (".", "..") for segment in resource_parts.path.split("/")):
253+
unquoted_path = unquote(resource_parts.path)
254+
if any(segment in (".", "..") for segment in unquoted_path.split("/")):
254255
raise ValueError(f"resource URI {resource_uri!r} contains a traversal segment")
255256

256257

@@ -290,7 +291,13 @@ def validate_directory_result(uri: str, result: ReadDirectoryResult) -> None:
290291
if child.scheme != scheme or child.netloc != netloc:
291292
raise ValueError(f"resource {resource.uri!r} is not a child of directory {uri!r}")
292293
relative = child.path.removeprefix(prefix)
293-
if relative == child.path or not relative or "/" in relative or relative in (".", ".."):
294+
unquoted_relative = unquote(relative)
295+
if (
296+
relative == child.path
297+
or not relative
298+
or "/" in unquoted_relative
299+
or unquoted_relative in (".", "..")
300+
):
294301
raise ValueError(f"resource {resource.uri!r} is not a direct child of directory {uri!r}")
295302
if resource.uri in seen_uris:
296303
raise ValueError(f"directory {uri!r} contains a duplicate child {resource.uri!r}")

‎tests/shared/test_skills.py‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,18 @@ def test_skill_rejects_a_resource_with_the_same_authority_but_a_sibling_path() -
149149
)
150150

151151

152-
def test_skill_rejects_a_traversal_segment_in_a_resource_uri() -> None:
152+
@pytest.mark.parametrize(
153+
"evil_uri",
154+
[
155+
"skill://git-workflow/../evil.md",
156+
"skill://git-workflow/%2e%2e/evil.md",
157+
"skill://git-workflow/%2E%2E/evil.md",
158+
"skill://git-workflow/%2e/evil.md",
159+
],
160+
)
161+
def test_skill_rejects_a_traversal_segment_in_a_resource_uri(evil_uri: str) -> None:
153162
with pytest.raises(ValidationError, match="traversal segment"):
154-
_skill(extra_resources=[_resource("skill://git-workflow/../evil.md")])
163+
_skill(extra_resources=[_resource(evil_uri)])
155164

156165

157166
def test_skill_rejects_a_resource_uri_with_a_trailing_slash() -> None:
@@ -342,7 +351,16 @@ def test_validate_directory_result_rejects_a_grandchild() -> None:
342351
validate_directory_result("skill://pdf/templates", result)
343352

344353

345-
@pytest.mark.parametrize("child_uri", ["skill://pdf/templates/.", "skill://pdf/templates/.."])
354+
@pytest.mark.parametrize(
355+
"child_uri",
356+
[
357+
"skill://pdf/templates/.",
358+
"skill://pdf/templates/..",
359+
"skill://pdf/templates/%2e",
360+
"skill://pdf/templates/%2e%2e",
361+
"skill://pdf/templates/%2E%2E",
362+
],
363+
)
346364
def test_validate_directory_result_rejects_a_dot_segment_child(child_uri: str) -> None:
347365
"""A `.`/`..` child is a traversal segment, not a real direct child of the directory."""
348366
result = ReadDirectoryResult(resources=[Resource(uri=child_uri, name="x")])

0 commit comments

Comments
 (0)