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 @@ -633,6 +633,13 @@ async def run(
url: str = "",
query_parameters: dict[str, Any] | None = None,
):
# The pagination link (e.g. ``@odata.nextLink``) is echoed from the API response and is
# re-fetched with the connection's bearer token attached. Kiota only scopes that token to
# ``allowed_hosts``, which defaults to empty (any host) unless configured, so a tampered
# response could redirect the token off-host. Pin follow-up requests to the configured
# endpoint's host (CWE-918).
allowed_netloc = urlparse((await self.get_async_conn()).base_url).netloc

while url:
response = await self.run(
url=url,
Expand All @@ -648,7 +655,7 @@ async def run(
responses.append(response)

if pagination_function:
url, query_parameters = execute_callable(
next_url, query_parameters = execute_callable(
pagination_function,
response=response,
url=url,
Expand All @@ -660,6 +667,16 @@ async def run(
data=data,
responses=lambda: responses,
)
if (
next_url
and next_url.startswith("http")
and urlparse(next_url).netloc != allowed_netloc
):
raise ValueError(
f"Refusing to follow pagination link {next_url!r}: its host differs "
f"from the configured Microsoft Graph endpoint {allowed_netloc!r}."
)
url = next_url
else:
break

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,25 @@ async def test_paginated_run(self):
assert isinstance(actual, list)
assert actual == [users, next_users]

@pytest.mark.asyncio
async def test_paginated_run_refuses_cross_host_next_link(self):
first_page = {
"@odata.nextLink": "https://attacker.example/v1.0/users?$skiptoken=steal",
"value": [{"id": "1"}],
}
response = mock_json_response(200, first_page)

with patch_hook_and_request_adapter(response) as mocks:
mock_get_http_response = mocks[-1]
hook = KiotaRequestAdapterHook(conn_id="msgraph_api")

with pytest.raises(ValueError, match="attacker.example"):
await hook.paginated_run(url="users")

# The off-host pagination link is refused before it is fetched, so the bearer
# token is never sent to the attacker host.
assert mock_get_http_response.call_count == 1

@pytest.mark.asyncio
async def test_build_request_adapter_masks_secrets(self):
"""Test that sensitive data is masked when building request adapter."""
Expand Down