This is an automated email from the ASF dual-hosted git repository.

dabla pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 5de1b18a012 Restrict MSGraph pagination nextLink to the configured 
host (#69742)
5de1b18a012 is described below

commit 5de1b18a0121018f2acc8befd4ac4a82f6ed35e0
Author: Samina <[email protected]>
AuthorDate: Mon Jul 13 15:43:40 2026 +0530

    Restrict MSGraph pagination nextLink to the configured host (#69742)
    
    * restrict msgraph pagination nextLink to the configured host
    
    * Compute pagination host check inside the run loop
    
    * Resolve pagination host once when building the request adapter
    
    Signed-off-by: bibi samina <[email protected]>
    
    ---------
    
    Signed-off-by: bibi samina <[email protected]>
    Co-authored-by: David Blain <[email protected]>
---
 .../providers/microsoft/azure/hooks/msgraph.py     | 19 +++++++++++++++++-
 .../unit/microsoft/azure/hooks/test_msgraph.py     | 23 ++++++++++++++++++++++
 2 files changed, 41 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 4952531dcc6..881a89fed07 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 @@ class KiotaRequestAdapterHook(BaseHook):
         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 @@ class KiotaRequestAdapterHook(BaseHook):
             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:
@@ -648,7 +655,7 @@ class KiotaRequestAdapterHook(BaseHook):
                     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 @@ class KiotaRequestAdapterHook(BaseHook):
                             data=data,
                             responses=lambda: responses,
                         )
+                        if (
+                            next_url
+                            and next_url.startswith("http")
+                            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 
{self.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 8eb60e91eed..d41407d4c03 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 @@ class TestKiotaRequestAdapterHook:
             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."""
@@ -588,6 +607,7 @@ class TestKiotaRequestAdapterHook:
 
             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()
@@ -607,6 +627,7 @@ class TestKiotaRequestAdapterHook:
 
             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()
@@ -622,6 +643,7 @@ class TestKiotaRequestAdapterHook:
             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()
@@ -637,6 +659,7 @@ class TestKiotaRequestAdapterHook:
             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)
 

Reply via email to