From 43ba2ef3af2c17ffa5709156c57642c2db0e5f26 Mon Sep 17 00:00:00 2001 From: Anna Tchijova Date: Sun, 30 Aug 2026 12:38:21 -0300 Subject: [PATCH] [azure-core] Warn when an LRO poll target host differs from the client's endpoint Long-running-operation polling reads the next poll URL from a service response header (Operation-Location / Azure-AsyncOperation / Location) and sends that GET through the client's credentialed pipeline. When the response names a host the client was not configured for, the poll request carries the client's credentials (API key or bearer token) to that host. SensitiveHeaderCleanupPolicy strips sensitive headers on cross-domain 3xx redirects, but the LRO poll target reaches a new host without a 3xx redirect, so that policy does not cover it. This adds a defense-in-depth warning (it does not block the request) in both the sync and async request_status paths when the poll target host differs from the initial request host, reusing get_domain for the comparison. Same-host and relative (no-host) poll targets are quiet, so services that legitimately poll the same host are unaffected. Left as a warning rather than a hard block deliberately: some services may poll a different host legitimately, and blocking by default would break them. A question for maintainers is on the PR about whether to escalate to a strip/reject. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VWmkpCnuhCPKFPD2fexY1a --- sdk/core/azure-core/CHANGELOG.md | 4 +++ .../azure/core/polling/async_base_polling.py | 2 ++ .../azure/core/polling/base_polling.py | 36 ++++++++++++++++++- .../azure-core/tests/test_base_polling.py | 28 +++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/sdk/core/azure-core/CHANGELOG.md b/sdk/core/azure-core/CHANGELOG.md index 759a911cace5..adf6fd91ea46 100644 --- a/sdk/core/azure-core/CHANGELOG.md +++ b/sdk/core/azure-core/CHANGELOG.md @@ -6,6 +6,10 @@ - Added `azure-deprecating` to the default allowed headers list in `HttpLoggingPolicy`, so deprecation notification headers are logged without redaction. +### Other Changes + +- Long-running-operation polling now logs a warning when the poll target host (taken from the `Operation-Location` / `Azure-AsyncOperation` / `Location` response header) differs from the client's configured endpoint host, since the poll request carries the client's credentials to that host. Same-host and relative poll targets are unaffected. + ## 1.41.0 (2026-05-07) ### Features Added diff --git a/sdk/core/azure-core/azure/core/polling/async_base_polling.py b/sdk/core/azure-core/azure/core/polling/async_base_polling.py index 5622e59c8309..d852b6ed9343 100644 --- a/sdk/core/azure-core/azure/core/polling/async_base_polling.py +++ b/sdk/core/azure-core/azure/core/polling/async_base_polling.py @@ -32,6 +32,7 @@ OperationFailed, _SansIOLROBasePolling, _raise_if_bad_http_status_and_method, + _warn_on_cross_host_poll_target, ) from ._async_poller import AsyncPollingMethod from ..pipeline._tools import is_rest @@ -154,6 +155,7 @@ async def request_status(self, status_link: str) -> PipelineResponse[HttpRequest """ if self._path_format_arguments: status_link = self._client.format_url(status_link, **self._path_format_arguments) + _warn_on_cross_host_poll_target(self._initial_response.http_response.request.url, status_link) # Re-inject 'x-ms-client-request-id' while polling if "request_id" not in self._operation_config: self._operation_config["request_id"] = self._get_request_id() diff --git a/sdk/core/azure-core/azure/core/polling/base_polling.py b/sdk/core/azure-core/azure/core/polling/base_polling.py index b88070a59d8c..dd385cc2fef6 100644 --- a/sdk/core/azure-core/azure/core/polling/base_polling.py +++ b/sdk/core/azure-core/azure/core/polling/base_polling.py @@ -26,6 +26,7 @@ import abc import base64 import json +import logging from enum import Enum from typing import ( Optional, @@ -43,7 +44,7 @@ from ..exceptions import HttpResponseError, DecodeError from . import PollingMethod -from ..pipeline.policies._utils import get_retry_after +from ..pipeline.policies._utils import get_retry_after, get_domain from ..pipeline._tools import is_rest from .._enum_meta import CaseInsensitiveEnumMeta from .. import PipelineClient @@ -63,6 +64,8 @@ _filter_sensitive_headers, ) +_LOGGER = logging.getLogger(__name__) + HttpRequestType = Union[LegacyHttpRequest, HttpRequest] HttpResponseType = Union[LegacyHttpResponse, HttpResponse] # Sync only @@ -225,6 +228,36 @@ def _is_empty(response: AllHttpResponseType) -> bool: return not bool(_get_content(response)) +def _warn_on_cross_host_poll_target(initial_request_url: str, status_link: str) -> None: + """Warn when the LRO poll target host differs from the client's configured endpoint. + + The poll target is taken verbatim from a service response header + (``Operation-Location`` / ``Azure-AsyncOperation`` / ``Location``) and is then sent + through the client's credentialed pipeline. If the response names a host the client + was not configured for, that request carries the client's credentials (API key or + bearer token) to that host. :class:`~azure.core.pipeline.policies.SensitiveHeaderCleanupPolicy` + strips sensitive headers on cross-domain 3xx redirects, but the LRO poll target + reaches a new host without a 3xx redirect, so that policy does not cover it. + + This logs a warning (it does not block the request) so it is safe for services that + legitimately poll a different host, while surfacing the credential-to-new-host case + to operators. A relative poll target (no host) resolves to the same host and is quiet. + + :param str initial_request_url: URL of the client's initial (trusted) request. + :param str status_link: The poll URL taken from the service response. + """ + initial_domain = get_domain(initial_request_url) + poll_domain = get_domain(status_link) + if initial_domain and poll_domain and poll_domain != initial_domain: + _LOGGER.warning( + "Long-running-operation poll target host '%s' differs from the client's configured " + "endpoint host '%s'. The poll request carries the client's credentials to that host. " + "Ensure the polled host is trusted.", + poll_domain, + initial_domain, + ) + + class LongRunningOperation(ABC, Generic[HTTPRequestType_co, HTTPResponseType_co]): """Protocol to implement for a long running operation algorithm.""" @@ -1013,6 +1046,7 @@ def request_status(self, status_link: str) -> PipelineResponse[HttpRequestTypeVa """ if self._path_format_arguments: status_link = self._client.format_url(status_link, **self._path_format_arguments) + _warn_on_cross_host_poll_target(self._initial_response.http_response.request.url, status_link) # Re-inject 'x-ms-client-request-id' while polling if "request_id" not in self._operation_config: self._operation_config["request_id"] = self._get_request_id() diff --git a/sdk/core/azure-core/tests/test_base_polling.py b/sdk/core/azure-core/tests/test_base_polling.py index a0abe95a9c7a..d71353cd1cd9 100644 --- a/sdk/core/azure-core/tests/test_base_polling.py +++ b/sdk/core/azure-core/tests/test_base_polling.py @@ -26,6 +26,7 @@ import base64 import datetime import json +import logging import re import types import platform @@ -987,3 +988,30 @@ def test_continuation_token_excludes_request_headers(port, http_request, deseria ) new_polling = LROBasePolling() new_polling.initialize(*polling_args) + + +def test_warn_on_cross_host_poll_target_warns_only_when_host_differs(caplog): + # The LRO poll target comes from a service response header and is sent through the + # client's credentialed pipeline. Warn when it names a host the client was not + # configured for (credentials would reach that host); stay quiet otherwise. + from azure.core.polling.base_polling import _warn_on_cross_host_poll_target + + logger_name = "azure.core.polling.base_polling" + initial = "https://victim.cognitiveservices.azure.com/analyze" + + with caplog.at_level(logging.WARNING, logger=logger_name): + _warn_on_cross_host_poll_target(initial, "https://attacker.example/poll") + assert any( + "differs from the client's configured endpoint host" in r.message for r in caplog.records + ) + + caplog.clear() + with caplog.at_level(logging.WARNING, logger=logger_name): + # Same host, same host with an explicit port, and a relative target must be quiet. + _warn_on_cross_host_poll_target(initial, "https://victim.cognitiveservices.azure.com/op/1") + _warn_on_cross_host_poll_target( + "https://victim.cognitiveservices.azure.com:443/analyze", + "https://victim.cognitiveservices.azure.com:443/op/1", + ) + _warn_on_cross_host_poll_target(initial, "/operations/1") + assert not caplog.records