Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 "/")
Comment on lines +159 to +160

if url_path.lower() == base_path.lower():
return True
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading