|
1 | 1 | """Tests for OAuth 2.0 Resource Indicators utilities.""" |
2 | 2 |
|
3 | | -from pydantic import HttpUrl |
| 3 | +import itertools |
| 4 | + |
| 5 | +import pytest |
| 6 | +from pydantic import AnyHttpUrl, HttpUrl |
4 | 7 |
|
5 | 8 | from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url |
6 | 9 |
|
@@ -46,6 +49,42 @@ def test_resource_url_from_server_url_handles_pydantic_urls(): |
46 | 49 | assert resource_url_from_server_url(url) == "https://example.com/path" |
47 | 50 |
|
48 | 51 |
|
| 52 | +@pytest.mark.parametrize( |
| 53 | + ("server_url", "expected"), |
| 54 | + [ |
| 55 | + ("https://example.com/api/../admin", "https://example.com/admin"), |
| 56 | + ("https://example.com/api/%2E%2e/admin", "https://example.com/admin"), |
| 57 | + ("https://example.com/api/.%2e/admin", "https://example.com/admin"), |
| 58 | + ("https://example.com/api/./v1", "https://example.com/api/v1"), |
| 59 | + ("https://example.com/api/v1/..", "https://example.com/api/"), |
| 60 | + ("https://example.com/api/v1/.", "https://example.com/api/v1/"), |
| 61 | + ("https://example.com/../admin", "https://example.com/admin"), |
| 62 | + ("https://example.com/a//b", "https://example.com/a//b"), |
| 63 | + ("https://example.com/a%2Fb/c", "https://example.com/a%2Fb/c"), |
| 64 | + ], |
| 65 | +) |
| 66 | +def test_resource_url_from_server_url_resolves_dot_segments(server_url: str, expected: str): |
| 67 | + """Dot-segments (including `%2E` spellings) are resolved per RFC 3986 section 5.2.4. |
| 68 | +
|
| 69 | + Empty segments and encoded slashes are not path separators and stay as written. |
| 70 | + """ |
| 71 | + assert resource_url_from_server_url(server_url) == expected |
| 72 | + |
| 73 | + |
| 74 | +def test_resource_url_from_server_url_path_matches_whatwg_resolution_for_literal_dot_segments(): |
| 75 | + """Every combination of literal `.`, `..`, empty and plain segments resolves as pydantic's WHATWG parser does. |
| 76 | +
|
| 77 | + The PRM `resource` side is parsed by `AnyHttpUrl`, so both operands of `check_resource_allowed` |
| 78 | + must agree on dot-segment resolution for the comparison to be meaningful. |
| 79 | + """ |
| 80 | + atoms = ["", ".", "..", "a", "b.", "..."] |
| 81 | + for count in range(1, 5): |
| 82 | + for segments in itertools.product(atoms, repeat=count): |
| 83 | + path = "/" + "/".join(segments) |
| 84 | + expected = AnyHttpUrl(f"https://example.com{path}").path |
| 85 | + assert resource_url_from_server_url(f"https://example.com{path}") == f"https://example.com{expected}" |
| 86 | + |
| 87 | + |
49 | 88 | # Tests for check_resource_allowed function |
50 | 89 |
|
51 | 90 |
|
@@ -121,3 +160,30 @@ def test_check_resource_allowed_empty_paths(): |
121 | 160 | assert check_resource_allowed("https://example.com", "https://example.com") is True |
122 | 161 | assert check_resource_allowed("https://example.com/", "https://example.com") is True |
123 | 162 | assert check_resource_allowed("https://example.com/api", "https://example.com") is True |
| 163 | + |
| 164 | + |
| 165 | +@pytest.mark.parametrize( |
| 166 | + "requested", |
| 167 | + [ |
| 168 | + "https://example.com/api/../admin", |
| 169 | + "https://example.com/api/%2e%2e/admin", |
| 170 | + "https://example.com/api/v1/../../admin", |
| 171 | + "https://example.com/api/..", |
| 172 | + ], |
| 173 | +) |
| 174 | +def test_check_resource_allowed_rejects_dot_segments_escaping_configured_path(requested: str): |
| 175 | + """A requested path that resolves outside the configured path is not a hierarchical match.""" |
| 176 | + assert check_resource_allowed(requested, "https://example.com/api") is False |
| 177 | + |
| 178 | + |
| 179 | +def test_check_resource_allowed_resolves_dot_segments_on_both_sides(): |
| 180 | + """Both URLs are compared in resolved form, so equivalent spellings agree (SDK-defined matching).""" |
| 181 | + assert check_resource_allowed("https://example.com/api/./v1", "https://example.com/api") is True |
| 182 | + assert check_resource_allowed("https://example.com/api/v1", "https://example.com/other/../api") is True |
| 183 | + assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/v1/../v2") is False |
| 184 | + |
| 185 | + |
| 186 | +def test_check_resource_allowed_keeps_encoded_slash_and_params_in_segment(): |
| 187 | + """`%2F` and `;params` are part of a segment (RFC 3986 sections 2.2, 3.3), not a boundary.""" |
| 188 | + assert check_resource_allowed("https://example.com/api%2Fv1", "https://example.com/api") is False |
| 189 | + assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api;x") is False |
0 commit comments