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.
This commit is contained in:
Max Isbey
2026-03-27 13:31:10 +00:00
parent c8712ff1eb
commit 19822fbbeb
3 changed files with 23 additions and 6 deletions
+6
View File
@@ -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
+11 -4
View File
@@ -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
+6 -2
View File
@@ -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
],