From 83921d434063cb69b662fbbba44eaa6ee5cc3188 Mon Sep 17 00:00:00 2001 From: bibi samina Date: Sat, 11 Jul 2026 10:19:35 +0530 Subject: [PATCH 1/2] restrict msgraph pagination nextLink to the configured host --- .../microsoft/azure/hooks/msgraph.py | 19 ++++++++++++++++++- .../microsoft/azure/hooks/test_msgraph.py | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py index 4952531dcc6fd..ad2a457cbfdfb 100644 --- a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py +++ b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py @@ -629,6 +629,13 @@ async def paginated_run( responses: list[dict] = [] + # 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 + async def run( url: str = "", query_parameters: dict[str, Any] | None = None, @@ -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, @@ -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 diff --git a/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py b/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py index 8eb60e91eed69..73ddfcfa0e158 100644 --- a/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py +++ b/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py @@ -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.""" From 3bf140057ebea746e6578910ba54720cc2b0104e Mon Sep 17 00:00:00 2001 From: bibi samina Date: Sat, 11 Jul 2026 19:44:41 +0530 Subject: [PATCH 2/2] Compute pagination host check inside the run loop Signed-off-by: bibi samina --- .../providers/microsoft/azure/hooks/msgraph.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py index ad2a457cbfdfb..fb7d90debd6ce 100644 --- a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py +++ b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py @@ -629,17 +629,17 @@ async def paginated_run( responses: list[dict] = [] - # 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 - 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,