Skip to content

Commit 19822fb

Browse files
committed
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.
1 parent c8712ff commit 19822fb

File tree

3 files changed

+23
-6
lines changed

3 files changed

+23
-6
lines changed

docs/server/resources.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,8 @@ There's no decorator; you return the protocol types yourself.
251251
For fixed URIs, keep a registry and dispatch on exact match:
252252

253253
```python
254+
from typing import Any
255+
254256
from mcp.server.lowlevel import Server
255257
from mcp.types import (
256258
ListResourcesResult,
@@ -309,6 +311,8 @@ Parse your templates once, then match incoming URIs against them in
309311
your read handler:
310312

311313
```python
314+
from typing import Any
315+
312316
from mcp.server.context import ServerRequestContext
313317
from mcp.server.lowlevel import Server
314318
from mcp.shared.uri_template import UriTemplate
@@ -373,6 +377,8 @@ the protocol `ResourceTemplate` type, using the same template strings
373377
you parsed above:
374378

375379
```python
380+
from typing import Any
381+
376382
from mcp.types import ListResourceTemplatesResult, PaginatedRequestParams, ResourceTemplate
377383

378384

src/mcp/shared/uri_template.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -819,8 +819,11 @@ def _check_ambiguous_adjacency(template: str, parts: list[_Part]) -> None:
819819
trailing match fails the engine backtracks through O(n) split
820820
points. Two conditions trigger this:
821821
822-
- ``{+var}`` immediately adjacent to any expression
823-
(``{+a}{b}``, ``{+a}{/b*}``)
822+
- ``{+var}`` immediately adjacent to any expression on either
823+
side (``{+a}{b}``, ``{a}{+b}``, ``{/a}{+b}``). The ``#``
824+
operator is exempt from the preceded-by case since it
825+
prepends a literal ``#`` that the preceding group cannot
826+
match.
824827
- Two ``{+var}``/``{#var}`` anywhere in the path, even with a
825828
literal between them (``{+a}/x/{+b}``) — the literal does not
826829
disambiguate since ``[^?#]*`` matches it too
@@ -836,25 +839,28 @@ def _check_ambiguous_adjacency(template: str, parts: list[_Part]) -> None:
836839
"""
837840
prev_explode = False
838841
prev_reserved = False
842+
prev_path_expr = False
839843
seen_reserved = False
840844
for part in parts:
841845
if isinstance(part, str):
842846
# A literal breaks immediate adjacency but does not reset
843847
# the seen-reserved count: [^?#]* matches most literals.
844848
prev_explode = False
845849
prev_reserved = False
850+
prev_path_expr = False
846851
continue
847852
for var in part.variables:
848853
# ?/& are stripped before pattern building and never reach
849854
# the path regex.
850855
if var.operator in ("?", "&"):
851856
prev_explode = False
852857
prev_reserved = False
858+
prev_path_expr = False
853859
continue
854860

855-
if prev_reserved:
861+
if prev_reserved or (var.operator == "+" and prev_path_expr):
856862
raise InvalidUriTemplate(
857-
"{+var} or {#var} immediately followed by another expression "
863+
"{+var} or {#var} immediately adjacent to another expression "
858864
"causes quadratic-time matching; separate them with a literal",
859865
template=template,
860866
)
@@ -872,5 +878,6 @@ def _check_ambiguous_adjacency(template: str, parts: list[_Part]) -> None:
872878

873879
prev_explode = var.explode
874880
prev_reserved = var.operator in ("+", "#")
881+
prev_path_expr = True
875882
if prev_reserved:
876883
seen_reserved = True

tests/shared/test_uri_template.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ def test_parse_rejects_adjacent_explodes(template: str):
166166
@pytest.mark.parametrize(
167167
"template",
168168
[
169-
# {+var} immediately adjacent to any expression
169+
# {+var} immediately adjacent to any expression (either side)
170170
"{+a}{b}",
171171
"{+a}{/b}",
172172
"{+a}{/b*}",
@@ -175,6 +175,10 @@ def test_parse_rejects_adjacent_explodes(template: str):
175175
"{#a}{b}",
176176
"{+a,b}", # multi-var in one expression: same adjacency
177177
"prefix/{+path}{.ext}", # literal before doesn't help
178+
"{a}{+b}", # + preceded by expression: same overlap
179+
"{.a}{+b}",
180+
"{/a}{+b}",
181+
"x{name}{+path}y",
178182
# Two {+var}/{#var} anywhere, even with literals between
179183
"{+a}/x/{+b}",
180184
"{+a},{+b}",
@@ -199,7 +203,7 @@ def test_parse_rejects_reserved_quadratic_patterns(template: str):
199203
"api/{+path}{?v,page}", # + followed by query (stripped before regex)
200204
"api/{+path}{&next}", # + followed by query-continuation
201205
"page{#section}", # # at end
202-
"{a}{+b}", # + preceded by expression is fine; only following matters
206+
"{a}{#b}", # # prepends literal '#' that {a}'s class excludes
203207
"{+a}/sep/{b}", # literal + bounded expression after: linear
204208
"{+a},{b}", # same: literal disambiguates when second is bounded
205209
],

0 commit comments

Comments
 (0)