From 83921d434063cb69b662fbbba44eaa6ee5cc3188 Mon Sep 17 00:00:00 2001 From: bibi samina Date: Sat, 11 Jul 2026 10:19:35 +0530 Subject: [PATCH 1/3] 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/3] 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, From 921af03788bc64dd8c23082b5f1f1fd8a64c1687 Mon Sep 17 00:00:00 2001 From: bibi samina Date: Mon, 13 Jul 2026 10:45:55 +0530 Subject: [PATCH 3/3] Resolve pagination host once when building the request adapter Signed-off-by: bibi samina --- .../providers/microsoft/azure/hooks/msgraph.py | 18 +++++++++--------- .../unit/microsoft/azure/hooks/test_msgraph.py | 4 ++++ 2 files changed, 13 insertions(+), 9 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 fb7d90debd6ce..881a89fed07ee 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 @@ -221,6 +221,7 @@ def __init__( else: self.scopes = scopes or [self.DEFAULT_SCOPE] self.api_version = self.resolve_api_version_from_value(api_version) + self.allowed_netloc: str | None = None def _ensure_protocol(self, host: str | None, schema: str = "https") -> str | None: """Ensure URL has http:// or https:// protocol prefix.""" @@ -490,6 +491,12 @@ async def get_async_conn(self) -> RequestAdapter: self.cached_request_adapters[self.conn_id] = (api_version, request_adapter) self.api_version = api_version + # 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). + self.allowed_netloc = urlparse(request_adapter.base_url).netloc return request_adapter def get_proxies(self, config: dict) -> dict | None: @@ -633,13 +640,6 @@ 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, @@ -670,11 +670,11 @@ async def run( if ( next_url and next_url.startswith("http") - and urlparse(next_url).netloc != allowed_netloc + and urlparse(next_url).netloc != self.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}." + f"from the configured Microsoft Graph endpoint {self.allowed_netloc!r}." ) url = next_url else: 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 73ddfcfa0e158..d41407d4c0359 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 @@ -607,6 +607,7 @@ async def test_get_async_conn_rebuilds_adapter_when_http_client_is_closed(self): fresh_adapter = Mock(spec=HttpxRequestAdapter) fresh_adapter._http_client = Mock(is_closed=False) + fresh_adapter.base_url = "https://graph.microsoft.com/v1.0" with patch.object(hook, "_build_request_adapter", return_value=("v1.0", fresh_adapter)): result = await hook.get_async_conn() @@ -626,6 +627,7 @@ async def test_get_async_conn_rebuilds_adapter_when_credentials_session_is_close fresh_adapter = Mock(spec=HttpxRequestAdapter) fresh_adapter._http_client = Mock(is_closed=False) + fresh_adapter.base_url = "https://graph.microsoft.com/v1.0" with patch.object(hook, "_build_request_adapter", return_value=("v1.0", fresh_adapter)): result = await hook.get_async_conn() @@ -641,6 +643,7 @@ async def test_get_async_conn_does_not_rebuild_adapter_when_transport_never_open adapter = Mock(spec=HttpxRequestAdapter) adapter._http_client = Mock(spec=AsyncClient, is_closed=False) adapter._authentication_provider = mock_authentication_provider() + adapter.base_url = "https://graph.microsoft.com/v1.0" hook.cached_request_adapters[hook.conn_id] = (hook.api_version, adapter) result = await hook.get_async_conn() @@ -656,6 +659,7 @@ async def test_send_request_invalidates_cache_and_raises_on_any_error(self): adapter = Mock(spec=HttpxRequestAdapter) adapter._http_client = Mock(spec=AsyncClient, is_closed=False) adapter._authentication_provider = mock_authentication_provider(closed=False) + adapter.base_url = "https://graph.microsoft.com/v1.0" adapter.send_no_response_content_async = AsyncMock(side_effect=RuntimeError("some error")) hook.cached_request_adapters[hook.conn_id] = (hook.api_version, adapter)