Skip to content

Commit 42e3f4b

Browse files
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
1 parent 660b1a4 commit 42e3f4b

2 files changed

Lines changed: 83 additions & 2 deletions

File tree

‎python/semantic_kernel/connectors/openapi_plugin/server_url_validator.py‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,51 @@ def _effective_port(url: ParseResult) -> int | None:
113113
return None
114114

115115

116+
def _normalize_path(path: str) -> str:
117+
"""Normalize a URL path the same way httpx does before routing.
118+
119+
Applies RFC 3986 §5.2.4 remove_dot_segments after decoding percent-encoded
120+
``.`` / ``..`` segments (``%2e`` / ``%2E``). Other percent-encodings are left
121+
intact. Empty segments are preserved. Paths that would climb above the root
122+
are clamped at ``/``.
123+
"""
124+
# Decode only %2e / %2E (dot) so encoded dot-segments resolve; leave other
125+
# encodings untouched so allowlist matching stays literal for them.
126+
decoded = ""
127+
i = 0
128+
lower = path.lower()
129+
while i < len(path):
130+
if lower[i : i + 3] == "%2e":
131+
decoded += "."
132+
i += 3
133+
else:
134+
decoded += path[i]
135+
i += 1
136+
137+
# remove_dot_segments (RFC 3986 §5.2.4), preserving empty segments like httpx.
138+
segments = decoded.split("/")
139+
resolved: list[str] = []
140+
for segment in segments:
141+
if segment == ".":
142+
continue
143+
if segment == "..":
144+
if resolved and resolved[-1] != "":
145+
resolved.pop()
146+
# else: clamp at root (leading empty from absolute path stays)
147+
continue
148+
resolved.append(segment)
149+
# Absolute paths keep a leading empty segment from the initial "/".
150+
if not resolved:
151+
return "/"
152+
normalized = "/".join(resolved)
153+
if decoded.startswith("/") and not normalized.startswith("/"):
154+
normalized = "/" + normalized
155+
return normalized or "/"
156+
157+
116158
def _matches_path_prefix(url_path: str, base_path: str) -> bool:
117-
url_path = url_path or "/"
118-
base_path = base_path or "/"
159+
url_path = _normalize_path(url_path or "/")
160+
base_path = _normalize_path(base_path or "/")
119161

120162
if url_path.lower() == base_path.lower():
121163
return True

‎python/tests/unit/connectors/openapi_plugin/test_server_url_validator.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,42 @@ async def test_validate_server_url_returns_no_addresses_for_allowed_base_url():
241241
async def test_validate_server_url_returns_no_addresses_when_private_access_is_allowed():
242242
options = ServerUrlValidationOptions(allow_private_network_access=True)
243243
assert await validate_server_url("https://internal.example/api", options) == []
244+
245+
246+
@pytest.mark.parametrize(
247+
"url",
248+
[
249+
"https://api.example.com/api/../admin",
250+
"https://api.example.com/api/%2e%2e/admin",
251+
"https://api.example.com/api/%2E%2E/admin",
252+
"https://api.example.com/api/foo/../../admin",
253+
],
254+
)
255+
async def test_validate_server_url_rejects_path_prefix_escape(url):
256+
"""Dot-segment escapes must not satisfy an /api allowlist prefix."""
257+
options = ServerUrlValidationOptions(allowed_base_urls=["https://api.example.com/api"])
258+
259+
with pytest.raises(FunctionExecutionException, match="allowed base URLs"):
260+
await validate_server_url(url, options)
261+
262+
263+
@pytest.mark.parametrize(
264+
"url",
265+
[
266+
"https://api.example.com/api/./users",
267+
"https://api.example.com/api/users",
268+
"https://api.example.com/api/",
269+
"https://api.example.com/api",
270+
],
271+
)
272+
async def test_validate_server_url_allows_normalized_paths_under_prefix(url):
273+
options = ServerUrlValidationOptions(allowed_base_urls=["https://api.example.com/api"])
274+
275+
assert await validate_server_url(url, options) == []
276+
277+
278+
async def test_validate_server_url_still_rejects_path_that_only_shares_prefix_chars():
279+
options = ServerUrlValidationOptions(allowed_base_urls=["https://api.example.com/api"])
280+
281+
with pytest.raises(FunctionExecutionException, match="allowed base URLs"):
282+
await validate_server_url("https://api.example.com/apiusers", options)

0 commit comments

Comments
 (0)