Skip to content

Commit f459160

Browse files
committed
Don't match URIs with non-UTF-8 percent-escapes in UriTemplate.match()
Fixes #3721
1 parent 2af9e30 commit f459160

3 files changed

Lines changed: 38 additions & 8 deletions

File tree

‎docs/servers/uri-templates.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,8 @@ Three things are happening in the highlighted lines:
239239
template; `template.match(uri)` returns the extracted variables as a
240240
`dict`, or `None` if the URI doesn't fit. URL decoding happens inside
241241
`match()`; the decoded values are returned as-is without path-safety
242-
validation. Values come out as strings: convert them yourself
242+
validation. A URI with a percent-escape that isn't valid UTF-8
243+
(`%FF`) doesn't match. Values come out as strings: convert them yourself
243244
(`int(matched["id"])`, `Path(matched["path"])`).
244245
* **Apply the safety checks yourself.** The `..` and absolute-path
245246
checks `MCPServer` runs by default live in `mcp.shared.path_security`.

‎src/mcp/shared/uri_template.py‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,9 @@ def match(self, uri: str, *, max_uri_length: int = DEFAULT_MAX_URI_LENGTH) -> di
472472
will not match across a literal ``/`` in the URI (the scan stops
473473
there), but a percent-encoded ``%2F`` that decodes to ``/`` is
474474
accepted as part of the value. Path-safety validation belongs at
475-
a higher layer; see :mod:`mcp.shared.path_security`.
475+
a higher layer; see :mod:`mcp.shared.path_security`. A URI whose
476+
percent-escapes don't decode as UTF-8 (e.g. ``%FF``) does not
477+
match.
476478
477479
Example::
478480
@@ -518,6 +520,14 @@ def match(self, uri: str, *, max_uri_length: int = DEFAULT_MAX_URI_LENGTH) -> di
518520
if len(uri) > max_uri_length:
519521
return None
520522

523+
try:
524+
return self._match(uri)
525+
except UnicodeDecodeError:
526+
# A %XX escape that isn't valid UTF-8 would otherwise decode
527+
# to U+FFFD and hand the handler a different value.
528+
return None
529+
530+
def _match(self, uri: str) -> dict[str, str | list[str]] | None:
521531
if self._query_variables:
522532
# Two-phase: scan matches the path, the query is split and
523533
# decoded manually. Query params may be partial, reordered,
@@ -583,6 +593,10 @@ def __str__(self) -> str:
583593
return self.template
584594

585595

596+
def _decode(value: str) -> str:
597+
return unquote(value, errors="strict")
598+
599+
586600
def _parse_query(query: str) -> dict[str, str]:
587601
"""Parse a query string into a name→value mapping.
588602
@@ -604,7 +618,7 @@ def _parse_query(query: str) -> dict[str, str]:
604618
for pair in query.split("&"):
605619
name, _, value = pair.partition("=")
606620
if name and name not in result:
607-
result[name] = unquote(value)
621+
result[name] = _decode(value)
608622
return result
609623

610624

@@ -622,7 +636,7 @@ def _extract_greedy(var: Variable, raw: str) -> str | list[str] | None:
622636
if not var.explode:
623637
if any(c in stops for c in raw):
624638
return None
625-
return unquote(raw)
639+
return _decode(raw)
626640

627641
sep = spec.separator
628642
if not raw:
@@ -654,7 +668,7 @@ def _extract_greedy(var: Variable, raw: str) -> str | list[str] | None:
654668
seg = ""
655669
else:
656670
return None
657-
segments.append(unquote(seg))
671+
segments.append(_decode(seg))
658672
return segments
659673

660674

@@ -1012,7 +1026,7 @@ def _scan_suffix(
10121026
eq = uri.find("=", earliest, pos)
10131027
if eq == -1:
10141028
return None
1015-
result[var.name] = unquote(uri[eq + 1 : pos])
1029+
result[var.name] = _decode(uri[eq + 1 : pos])
10161030
pos = eq
10171031
i -= 1
10181032
continue
@@ -1045,7 +1059,7 @@ def _scan_suffix(
10451059
return None
10461060
start = idx + len(prev.text)
10471061

1048-
result[var.name] = unquote(uri[start:pos])
1062+
result[var.name] = _decode(uri[start:pos])
10491063
pos = start
10501064
i -= 1
10511065
return result, pos
@@ -1111,6 +1125,6 @@ def _scan_prefix(
11111125
if end == -1:
11121126
return None
11131127

1114-
result[var.name] = unquote(uri[pos:end])
1128+
result[var.name] = _decode(uri[pos:end])
11151129
pos = end
11161130
return result, pos

‎tests/shared/test_uri_template.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,21 @@ def test_match_decodes_percent_encoding():
764764
assert t.match("file://docs/hello%20world.txt") == {"name": "hello world.txt"}
765765

766766

767+
@pytest.mark.parametrize(
768+
("template", "uri"),
769+
[
770+
("file://docs/{name}", "file://docs/%FF"),
771+
("file://docs/{+path}", "file://docs/a/%C3"),
772+
("/files{/path*}", "/files/a/%FFb"),
773+
("{;filter}", ";filter=%80"),
774+
("logs://{service}{?level}", "logs://api?level=%FE"),
775+
("{+path}/end{.ext}", "a/end.%FF"),
776+
],
777+
)
778+
def test_match_rejects_invalid_utf8_escapes(template: str, uri: str):
779+
assert UriTemplate.parse(template).match(uri) is None
780+
781+
767782
def test_match_escapes_template_literals():
768783
# Regression: previous impl didn't escape . in literals, making it
769784
# a regex wildcard. "fileXtxt" should NOT match "file.txt/{id}".

0 commit comments

Comments
 (0)