Skip to content

fix: reject URL-encoded path separators in resource template parameters#2353

Open
sebastiondev wants to merge 1 commit intomodelcontextprotocol:mainfrom
sebastiondev:security/fix-path-traversal-in-resource-templates
Open

fix: reject URL-encoded path separators in resource template parameters#2353
sebastiondev wants to merge 1 commit intomodelcontextprotocol:mainfrom
sebastiondev:security/fix-path-traversal-in-resource-templates

Conversation

@sebastiondev
Copy link

Vulnerability Summary

CWE-22: Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal")
Severity: High
Affected file: src/mcp/server/mcpserver/resources/templates.pyResourceTemplate.matches()

Data Flow

  1. A client sends an MCP resources/read request with a crafted URI (e.g. file://documents/..%2F..%2F..%2Fetc%2Fpasswd).
  2. MCPServer._handle_read_resource() routes the request to ResourceManager.get_resource() (line 94, resource_manager.py).
  3. ResourceTemplate.matches() converts the URI template to a regex where each {param} becomes (?P<param>[^/]+).
  4. The regex matches the encoded URI — ..%2F..%2F..%2Fetc%2Fpasswd contains no literal /, so [^/]+ succeeds.
  5. unquote() then decodes it to ../../../etc/passwd, which is returned as the parameter value.
  6. If the server-side resource handler uses this value in file-system operations (a common pattern shown in official examples), path traversal occurs.

Exploit Sketch

# Server code (common pattern from official examples)
@mcp.resource("file://documents/{name}")
def read_document(name: str) -> str:
    return Path(f"/data/documents/{name}").read_text()
// Attack payload
{"method": "resources/read", "params": {"uri": "file://documents/..%2F..%2F..%2Fsecrets%2Fapi_keys.json"}}

Before fix: name = "../../../secrets/api_keys.json" → resolves to /secrets/api_keys.json
After fix: matches() returns None → request is rejected


Fix Description

The fix adds a post-decode validation step in ResourceTemplate.matches(). After URL-decoding extracted parameter values, each value is re-validated against the same [^/]+ segment constraint that was applied to the encoded form. If a decoded value contains a / character (which could only come from an encoded %2F), matches() returns None.

Rationale

  • Single chokepoint: matches() is the only code path that extracts template parameters from URIs in the MCP server framework. Fixing it here provides defense-in-depth for all resource handlers.
  • Minimal and targeted: 1 file changed, 21 insertions, 1 deletion. No behavioral change for legitimate URIs (which never contain encoded path separators in parameter segments).
  • Consistent with RFC 3986 §2.2: / is a reserved character; %2F in a path segment should not be treated as equivalent to a literal / for matching purposes.
  • Prior art: This is the same class of vulnerability as CVE-2021-41773 (Apache httpd path traversal via %2e encoding) and similar issues fixed in Flask/Werkzeug.

Change Details

# New module-level constant
_SEGMENT_RE = re.compile(r"[^/]+")

# In matches(), after decoding:
decoded = {key: unquote(value) for key, value in match.groupdict().items()}
for value in decoded.values():
    if not _SEGMENT_RE.fullmatch(value):
        return None
return decoded

Test Results Summary

Tested the following scenarios to confirm correctness:

Test Case Input URI Expected Result
Normal match file://docs/readme.txt {"name": "readme.txt"} ✅ Pass
Encoded slash %2F file://docs/..%2F..%2Fetc%2Fpasswd None (rejected) ✅ Pass
Encoded slash %2f (lowercase) file://docs/..%2f..%2fetc%2fpasswd None (rejected) ✅ Pass
Double-encoded %252F file://docs/..%252F..%252Fetc {"name": "..%2F..%2Fetc"} (single decode, no /) ✅ Pass
Legitimate encoded chars file://docs/hello%20world.txt {"name": "hello world.txt"} ✅ Pass
Multiple parameters file://{org}/{repo} with %2F in one param None (rejected) ✅ Pass
Empty-after-decode file://docs/%2F None (rejected) ✅ Pass
Non-matching URI file://other/path against file://docs/{name} None ✅ Pass (no regression)

Disprove Analysis

We systematically attempted to disprove this finding across multiple dimensions:

Authentication Check

No authentication on ResourceTemplate.matches() or read_resource. Auth is opt-in via MCPServer(auth=...). Default servers run without auth. The resources/read handler is accessible to any connected MCP client.

Network Check

Default transport is stdio (local process). HTTP transports default to 127.0.0.1 with DNS rebinding protection. However, servers can be configured to bind to 0.0.0.0 or deployed over HTTP — the README shows streamable HTTP examples suggesting production HTTP use is expected.

Caller Trace

matches() is called from exactly one place: ResourceManager.get_resource()MCPServer.read_resource()_handle_read_resource(). The URI comes directly from params.uri (client-supplied). Attacker-controlled data reaches this code path.

Prior Validation

Zero validation existed on decoded parameter values before this fix. Pydantic validate_call only enforces type (str), not content.

Existing Mitigations

  • Default stdio transport reduces remote attack surface
  • HTTP transport defaults to localhost with DNS rebinding protection
  • OAuth auth is available but opt-in
  • No framework-level path sanitization beyond this fix

Similar Code Paths

The low-level server API does not use ResourceTemplate — it delegates to user callbacks directly. This is not a parallel vulnerability because low-level users don't use templates and are responsible for their own parsing. No other instances of template parameter extraction without post-decode validation exist.

Known Limitation

The fix does not block %5C\ (Windows backslash path separator). On Windows, ..\..\secret is a valid traversal. This is a separate concern given the URI spec uses / exclusively, and could be addressed in a follow-up if desired.

Verdict

Confirmed valid with high confidence. The vulnerability is clearly demonstrable, the fix is correct and minimal, and it addresses the primary attack vector at the single chokepoint for template parameter extraction.


Preconditions for Exploitation

  1. MCP client access: Attacker must send MCP protocol messages (via compromised/prompt-injected LLM, direct HTTP connection, or local stdio)
  2. Vulnerable resource handler: Server must use extracted template parameters in file-system operations without additional validation
  3. No additional path sanitization in user handler code

This fix was prepared through a 4-stage review process including vulnerability identification, fix development, testing, and adversarial disprove analysis.

ResourceTemplate.matches() URL-decodes extracted parameters but did not
re-validate that the decoded values still satisfy the [^/]+ segment
constraint. An attacker could send a URI like:

    files://..%2F..%2Fetc%2Fpasswd

The encoded %2F passes the regex match on the raw URI, but after
unquote() it becomes ../../etc/passwd — a path traversal payload that
is then passed directly to the template function via fn(**params).

The fix re-checks every decoded parameter value against the original
[^/]+ pattern and returns None (no match) if any value now contains a
forward slash.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant