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
4 changes: 4 additions & 0 deletions sdk/core/azure-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
36 changes: 35 additions & 1 deletion sdk/core/azure-core/azure/core/polling/base_polling.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import abc
import base64
import json
import logging
from enum import Enum
from typing import (
Optional,
Expand All @@ -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
Expand All @@ -63,6 +64,8 @@
_filter_sensitive_headers,
)

_LOGGER = logging.getLogger(__name__)


HttpRequestType = Union[LegacyHttpRequest, HttpRequest]
HttpResponseType = Union[LegacyHttpResponse, HttpResponse] # Sync only
Expand Down Expand Up @@ -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:
Comment on lines +249 to +251
_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."""

Expand Down Expand Up @@ -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()
Expand Down
28 changes: 28 additions & 0 deletions sdk/core/azure-core/tests/test_base_polling.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import base64
import datetime
import json
import logging
import re
import types
import platform
Expand Down Expand Up @@ -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
Loading