From 42e3f4b82dc7be211d5bcc0748a08beff18d89d6 Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Tue, 27 Oct 2026 08:26:06 +0000 Subject: [PATCH] Python: normalize OpenAPI allowlist paths before prefix match Decode %2e/%2E and apply RFC 3986 remove_dot_segments so allowed_base_urls cannot be escaped with .. or encoded dots. Matches httpx path normalization; empty segments preserved. Fixes #14662 --- .../openapi_plugin/server_url_validator.py | 46 ++++++++++++++++++- .../test_server_url_validator.py | 39 ++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/connectors/openapi_plugin/server_url_validator.py b/python/semantic_kernel/connectors/openapi_plugin/server_url_validator.py index ed8258fce36a..21d0e51c2c50 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/server_url_validator.py +++ b/python/semantic_kernel/connectors/openapi_plugin/server_url_validator.py @@ -113,9 +113,51 @@ def _effective_port(url: ParseResult) -> int | None: return None +def _normalize_path(path: str) -> str: + """Normalize a URL path the same way httpx does before routing. + + Applies RFC 3986 §5.2.4 remove_dot_segments after decoding percent-encoded + ``.`` / ``..`` segments (``%2e`` / ``%2E``). Other percent-encodings are left + intact. Empty segments are preserved. Paths that would climb above the root + are clamped at ``/``. + """ + # Decode only %2e / %2E (dot) so encoded dot-segments resolve; leave other + # encodings untouched so allowlist matching stays literal for them. + decoded = "" + i = 0 + lower = path.lower() + while i < len(path): + if lower[i : i + 3] == "%2e": + decoded += "." + i += 3 + else: + decoded += path[i] + i += 1 + + # remove_dot_segments (RFC 3986 §5.2.4), preserving empty segments like httpx. + segments = decoded.split("/") + resolved: list[str] = [] + for segment in segments: + if segment == ".": + continue + if segment == "..": + if resolved and resolved[-1] != "": + resolved.pop() + # else: clamp at root (leading empty from absolute path stays) + continue + resolved.append(segment) + # Absolute paths keep a leading empty segment from the initial "/". + if not resolved: + return "/" + normalized = "/".join(resolved) + if decoded.startswith("/") and not normalized.startswith("/"): + normalized = "/" + normalized + return normalized or "/" + + def _matches_path_prefix(url_path: str, base_path: str) -> bool: - url_path = url_path or "/" - base_path = base_path or "/" + url_path = _normalize_path(url_path or "/") + base_path = _normalize_path(base_path or "/") if url_path.lower() == base_path.lower(): return True diff --git a/python/tests/unit/connectors/openapi_plugin/test_server_url_validator.py b/python/tests/unit/connectors/openapi_plugin/test_server_url_validator.py index e6d6f900e6ec..36fc214b99a8 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_server_url_validator.py +++ b/python/tests/unit/connectors/openapi_plugin/test_server_url_validator.py @@ -241,3 +241,42 @@ async def test_validate_server_url_returns_no_addresses_for_allowed_base_url(): async def test_validate_server_url_returns_no_addresses_when_private_access_is_allowed(): options = ServerUrlValidationOptions(allow_private_network_access=True) assert await validate_server_url("https://internal.example/api", options) == [] + + +@pytest.mark.parametrize( + "url", + [ + "https://api.example.com/api/../admin", + "https://api.example.com/api/%2e%2e/admin", + "https://api.example.com/api/%2E%2E/admin", + "https://api.example.com/api/foo/../../admin", + ], +) +async def test_validate_server_url_rejects_path_prefix_escape(url): + """Dot-segment escapes must not satisfy an /api allowlist prefix.""" + options = ServerUrlValidationOptions(allowed_base_urls=["https://api.example.com/api"]) + + with pytest.raises(FunctionExecutionException, match="allowed base URLs"): + await validate_server_url(url, options) + + +@pytest.mark.parametrize( + "url", + [ + "https://api.example.com/api/./users", + "https://api.example.com/api/users", + "https://api.example.com/api/", + "https://api.example.com/api", + ], +) +async def test_validate_server_url_allows_normalized_paths_under_prefix(url): + options = ServerUrlValidationOptions(allowed_base_urls=["https://api.example.com/api"]) + + assert await validate_server_url(url, options) == [] + + +async def test_validate_server_url_still_rejects_path_that_only_shares_prefix_chars(): + options = ServerUrlValidationOptions(allowed_base_urls=["https://api.example.com/api"]) + + with pytest.raises(FunctionExecutionException, match="allowed base URLs"): + await validate_server_url("https://api.example.com/apiusers", options)