From 19822fbbebcd9aca620fdecde1b6070e59ac4441 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:31:10 +0000 Subject: [PATCH] fix: reject {expr}{+var} adjacency to close ReDoS gap The adjacency check rejected {+a}{b} but not the symmetric {a}{+b}. Both produce overlapping greedy quantifiers; a 64KB crafted input against prefix{a}{+b}.json takes ~23s to reject. Added prev_path_expr tracking so {+var} immediately after any path expression is rejected. {expr}{#var} remains allowed since the # operator prepends a literal '#' that the preceding group's character class excludes, giving a natural boundary. Also adds the missing 'from typing import Any' to the three low-level server examples in docs/server/resources.md. --- docs/server/resources.md | 6 ++++++ src/mcp/shared/uri_template.py | 15 +++++++++++---- tests/shared/test_uri_template.py | 8 ++++++-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/server/resources.md b/docs/server/resources.md index 0ca6d2c3..10562689 100644 --- a/docs/server/resources.md +++ b/docs/server/resources.md @@ -251,6 +251,8 @@ There's no decorator; you return the protocol types yourself. For fixed URIs, keep a registry and dispatch on exact match: ```python +from typing import Any + from mcp.server.lowlevel import Server from mcp.types import ( ListResourcesResult, @@ -309,6 +311,8 @@ Parse your templates once, then match incoming URIs against them in your read handler: ```python +from typing import Any + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from mcp.shared.uri_template import UriTemplate @@ -373,6 +377,8 @@ the protocol `ResourceTemplate` type, using the same template strings you parsed above: ```python +from typing import Any + from mcp.types import ListResourceTemplatesResult, PaginatedRequestParams, ResourceTemplate diff --git a/src/mcp/shared/uri_template.py b/src/mcp/shared/uri_template.py index e71357b0..286024e1 100644 --- a/src/mcp/shared/uri_template.py +++ b/src/mcp/shared/uri_template.py @@ -819,8 +819,11 @@ def _check_ambiguous_adjacency(template: str, parts: list[_Part]) -> None: trailing match fails the engine backtracks through O(n) split points. Two conditions trigger this: - - ``{+var}`` immediately adjacent to any expression - (``{+a}{b}``, ``{+a}{/b*}``) + - ``{+var}`` immediately adjacent to any expression on either + side (``{+a}{b}``, ``{a}{+b}``, ``{/a}{+b}``). The ``#`` + operator is exempt from the preceded-by case since it + prepends a literal ``#`` that the preceding group cannot + match. - Two ``{+var}``/``{#var}`` anywhere in the path, even with a literal between them (``{+a}/x/{+b}``) — the literal does not disambiguate since ``[^?#]*`` matches it too @@ -836,6 +839,7 @@ def _check_ambiguous_adjacency(template: str, parts: list[_Part]) -> None: """ prev_explode = False prev_reserved = False + prev_path_expr = False seen_reserved = False for part in parts: if isinstance(part, str): @@ -843,6 +847,7 @@ def _check_ambiguous_adjacency(template: str, parts: list[_Part]) -> None: # the seen-reserved count: [^?#]* matches most literals. prev_explode = False prev_reserved = False + prev_path_expr = False continue for var in part.variables: # ?/& are stripped before pattern building and never reach @@ -850,11 +855,12 @@ def _check_ambiguous_adjacency(template: str, parts: list[_Part]) -> None: if var.operator in ("?", "&"): prev_explode = False prev_reserved = False + prev_path_expr = False continue - if prev_reserved: + if prev_reserved or (var.operator == "+" and prev_path_expr): raise InvalidUriTemplate( - "{+var} or {#var} immediately followed by another expression " + "{+var} or {#var} immediately adjacent to another expression " "causes quadratic-time matching; separate them with a literal", template=template, ) @@ -872,5 +878,6 @@ def _check_ambiguous_adjacency(template: str, parts: list[_Part]) -> None: prev_explode = var.explode prev_reserved = var.operator in ("+", "#") + prev_path_expr = True if prev_reserved: seen_reserved = True diff --git a/tests/shared/test_uri_template.py b/tests/shared/test_uri_template.py index 5d1a3a1b..8594d667 100644 --- a/tests/shared/test_uri_template.py +++ b/tests/shared/test_uri_template.py @@ -166,7 +166,7 @@ def test_parse_rejects_adjacent_explodes(template: str): @pytest.mark.parametrize( "template", [ - # {+var} immediately adjacent to any expression + # {+var} immediately adjacent to any expression (either side) "{+a}{b}", "{+a}{/b}", "{+a}{/b*}", @@ -175,6 +175,10 @@ def test_parse_rejects_adjacent_explodes(template: str): "{#a}{b}", "{+a,b}", # multi-var in one expression: same adjacency "prefix/{+path}{.ext}", # literal before doesn't help + "{a}{+b}", # + preceded by expression: same overlap + "{.a}{+b}", + "{/a}{+b}", + "x{name}{+path}y", # Two {+var}/{#var} anywhere, even with literals between "{+a}/x/{+b}", "{+a},{+b}", @@ -199,7 +203,7 @@ def test_parse_rejects_reserved_quadratic_patterns(template: str): "api/{+path}{?v,page}", # + followed by query (stripped before regex) "api/{+path}{&next}", # + followed by query-continuation "page{#section}", # # at end - "{a}{+b}", # + preceded by expression is fine; only following matters + "{a}{#b}", # # prepends literal '#' that {a}'s class excludes "{+a}/sep/{b}", # literal + bounded expression after: linear "{+a},{b}", # same: literal disambiguates when second is bounded ],