Skip to content

Commit a463ed9

Browse files
committed
test: add adversarial security test cases for layered defense
Adds coverage for encoding-based attack vectors across both security layers: Layer 1 (structural integrity in UriTemplate.match): - Double-encoding %252F decoded once, accepted as literal %2F - Multi-param template with one poisoned value rejects whole match - Value decoding to only the forbidden delimiter rejected Layer 2 (ResourceSecurity traversal check): - %5C backslash passes structural, caught by traversal normalization - %2E%2E encoded dots pass structural, caught by traversal check - Mixed encoded+literal slash fails at regex before decoding
1 parent 2575042 commit a463ed9

File tree

2 files changed

+45
-0
lines changed

2 files changed

+45
-0
lines changed

tests/server/mcpserver/resources/test_resource_template.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,29 @@ def test_matches_explode_checks_each_segment():
7373
assert t.matches("api/a/../c") is None
7474

7575

76+
def test_matches_encoded_backslash_caught_by_traversal_layer():
77+
# %5C decodes to '\\'. Backslash is not a URI delimiter, so it passes
78+
# structural integrity (layer 1). The traversal check (layer 2)
79+
# normalizes '\\' to '/' and catches the '..' components.
80+
t = _make("file://docs/{name}")
81+
assert t.matches("file://docs/..%5C..%5Csecret") is None
82+
83+
84+
def test_matches_encoded_dots_caught_by_traversal_layer():
85+
# %2E%2E decodes to '..'. Contains no structural delimiter, so passes
86+
# layer 1. Layer 2's traversal check catches the '..' component.
87+
t = _make("file://docs/{name}")
88+
assert t.matches("file://docs/%2E%2E") is None
89+
90+
91+
def test_matches_mixed_encoded_and_literal_slash():
92+
# One encoded slash + one literal: literal '/' prevents the regex
93+
# match at layer 0 (simple var stops at '/'), so this never reaches
94+
# decoding. Different failure mode than pure-encoded traversal.
95+
t = _make("file://docs/{name}")
96+
assert t.matches("file://docs/..%2F../etc") is None
97+
98+
7699
def test_matches_escapes_template_literals():
77100
# Regression: old impl treated . as regex wildcard
78101
t = _make("data://v1.0/{id}")

tests/shared/test_uri_template.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,28 @@ def test_match_structural_integrity_allows_slash_in_reserved():
387387
assert t.match("a/b") == {"path": "a/b"}
388388

389389

390+
def test_match_double_encoding_decoded_once():
391+
# %252F is %2F encoded again. Single decode gives "%2F" (a literal
392+
# percent sign, a '2', and an 'F'), which contains no '/' and should
393+
# be accepted. Guards against over-decoding.
394+
t = UriTemplate.parse("file://docs/{name}")
395+
assert t.match("file://docs/..%252Fetc") == {"name": "..%2Fetc"}
396+
397+
398+
def test_match_multi_param_one_poisoned_rejects_whole():
399+
# One bad param in a multi-param template rejects the entire match
400+
t = UriTemplate.parse("file://{org}/{repo}")
401+
assert t.match("file://acme/..%2Fsecret") is None
402+
# But the same template with clean params matches fine
403+
assert t.match("file://acme/project") == {"org": "acme", "repo": "project"}
404+
405+
406+
def test_match_bare_encoded_delimiter_rejected():
407+
# A value that decodes to only the forbidden delimiter
408+
t = UriTemplate.parse("file://docs/{name}")
409+
assert t.match("file://docs/%2F") is None
410+
411+
390412
def test_match_structural_integrity_per_explode_segment():
391413
t = UriTemplate.parse("/files{/path*}")
392414
# Each segment checked independently

0 commit comments

Comments
 (0)