Skip to content

Commit 0569215

Browse files
authored
Fix the redirect auth issues (#47265)
* Fix the redirect auth issues * Add CHANGELOG * Address feedback * Fix format
1 parent 3a1f299 commit 0569215

6 files changed

Lines changed: 140 additions & 21 deletions

File tree

sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
- Use `APPLICATIONINSIGHTS_PYTHON_ATTACHTYPE` environment variable in `_is_attach_enabled` to
1010
reliably detect successful auto-instrumentation attach, with fallback to legacy path-based detection
1111
([#46955](https://github.com/Azure/azure-sdk-for-python/pull/46955))
12-
12+
- Safeguard URL redirection issues
13+
([#47265](https://github.com/Azure/azure-sdk-for-python/pull/47265))
1314
### Breaking Changes
1415

1516
### Bugs Fixed

sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_constants.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@
4949
308, # Permanent redirect
5050
)
5151

52+
_ALLOWED_REDIRECT_DOMAIN_SUFFIXES = (
53+
".livediagnostics.monitor.azure.com",
54+
".monitor.azure.com",
55+
".services.visualstudio.com",
56+
".applicationinsights.azure.com",
57+
".monitor.azure.us",
58+
".applicationinsights.azure.us",
59+
".monitor.azure.cn",
60+
".applicationinsights.azure.cn",
61+
)
62+
5263
_RETRYABLE_STATUS_CODES = (
5364
401, # Unauthorized
5465
403, # Forbidden

sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_quickpulse/_policy.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
from azure.core.pipeline import PipelineResponse, policies
1010

11+
from azure.monitor.opentelemetry.exporter._constants import (
12+
_ALLOWED_REDIRECT_DOMAIN_SUFFIXES,
13+
)
1114
from azure.monitor.opentelemetry.exporter._quickpulse._constants import (
1215
_QUICKPULSE_REDIRECT_HEADER_NAME,
1316
)
@@ -17,19 +20,6 @@
1720

1821
_logger = logging.getLogger(__name__)
1922

20-
# Allowed domain suffixes for QuickPulse redirect targets.
21-
# Only redirects to these trusted Azure Monitor domains are accepted.
22-
_ALLOWED_REDIRECT_DOMAIN_SUFFIXES = (
23-
".livediagnostics.monitor.azure.com",
24-
".monitor.azure.com",
25-
".services.visualstudio.com",
26-
".applicationinsights.azure.com",
27-
".monitor.azure.us",
28-
".applicationinsights.azure.us",
29-
".monitor.azure.cn",
30-
".applicationinsights.azure.cn",
31-
)
32-
3323

3424
def _is_redirect_target_allowed(netloc: str) -> bool:
3525
"""Validate that the redirect target host belongs to a known Azure Monitor domain.

sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/_base.py

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
TelemetryItem,
2929
)
3030
from azure.monitor.opentelemetry.exporter._constants import (
31+
_ALLOWED_REDIRECT_DOMAIN_SUFFIXES,
3132
_AZURE_MONITOR_DISTRO_VERSION_ARG,
3233
_APPLICATIONINSIGHTS_AUTHENTICATION_STRING,
3334
_INVALID_STATUS_CODES,
@@ -458,10 +459,32 @@ def _transmit(self, envelopes: List[TelemetryItem], _skip_rate_limit: bool = Fal
458459
else:
459460
redirect_has_headers = False
460461
if redirect_has_headers and url.scheme and url.netloc: # pylint: disable=E0606
461-
# Change the host to the new redirected host
462-
self.client._config.host = "{}://{}".format(url.scheme, url.netloc) # pylint: disable=W0212
463-
# Attempt to export again
464-
result = self._transmit(envelopes, _skip_rate_limit=True)
462+
current_url = urlparse(self.client._config.host) # pylint: disable=W0212
463+
# Refuse cross-origin redirects so an attacker-controlled
464+
# `Location` header cannot cause the auth policy to attach
465+
# a freshly-signed Authorization header for a foreign host
466+
# on the recursive _transmit call.
467+
if not self._is_same_registered_domain(current_url.netloc, url.netloc):
468+
if not self._is_stats_exporter():
469+
if self._should_collect_customer_sdkstats():
470+
track_dropped_items(
471+
envelopes,
472+
DropCode.CLIENT_EXCEPTION,
473+
_exception_categories.CLIENT_EXCEPTION.value,
474+
)
475+
logger.error(
476+
"Refusing cross-origin redirect to %s://%s.",
477+
url.scheme,
478+
url.netloc,
479+
)
480+
result = ExportResult.FAILED_NOT_RETRYABLE
481+
else:
482+
# Change the host to the new redirected host
483+
self.client._config.host = "{}://{}".format(
484+
url.scheme, url.netloc
485+
) # pylint: disable=W0212
486+
# Attempt to export again
487+
result = self._transmit(envelopes, _skip_rate_limit=True)
465488
else:
466489
if not self._is_stats_exporter():
467490
if self._should_collect_customer_sdkstats():
@@ -611,6 +634,46 @@ def _is_stats_exporter(self):
611634
def _is_customer_sdkstats_exporter(self):
612635
return getattr(self, "_is_customer_sdkstats", False)
613636

637+
def _is_same_registered_domain(self, current_netloc: str, redirect_netloc: str) -> bool:
638+
"""Return True if the redirect target is safe to follow.
639+
640+
Used to gate redirects so an attacker-controlled ``Location`` header
641+
cannot cause the exporter (and its credential-bearing pipeline) to
642+
send telemetry and the Authorization header to an unrelated host.
643+
644+
A redirect is permitted only when the target equals the currently
645+
configured host exactly, or when both the current host and the
646+
redirect target are under one of the known Azure Monitor ingestion
647+
host suffixes (see ``_ALLOWED_REDIRECT_DOMAIN_SUFFIXES``). Customers
648+
with a custom (non-Azure) ingestion host will therefore not have
649+
server-issued cross-host redirects followed; such deployments should
650+
configure their proxy to terminate redirects locally.
651+
652+
:param str current_netloc: The netloc of the currently-configured ingestion endpoint.
653+
:param str redirect_netloc: The netloc of the redirect target from the server's location header.
654+
:return: True if the redirect target is considered safe to follow.
655+
:rtype: bool
656+
"""
657+
658+
def _host(netloc: str) -> str:
659+
return netloc.split("@")[-1].split(":")[0].lower().rstrip(".")
660+
661+
if not current_netloc or not redirect_netloc:
662+
return False
663+
current_host = _host(current_netloc)
664+
redirect_host = _host(redirect_netloc)
665+
if not current_host or not redirect_host:
666+
return False
667+
# Exact host match is always safe.
668+
if current_host == redirect_host:
669+
return True
670+
# Otherwise both hosts must live under the same trusted Azure Monitor
671+
# ingestion suffix.
672+
for suffix in _ALLOWED_REDIRECT_DOMAIN_SUFFIXES:
673+
if current_host.endswith(suffix) and redirect_host.endswith(suffix):
674+
return True
675+
return False
676+
614677

615678
def _is_invalid_code(response_code: Optional[int]) -> bool:
616679
"""Determine if response is a invalid response.

sdk/monitor/azure-monitor-opentelemetry-exporter/tests/test_base_exporter.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -747,7 +747,11 @@ def test_transmit_http_error_not_retryable(self):
747747
def test_transmit_http_error_redirect(self):
748748
response = HttpResponse(None, None)
749749
response.status_code = 307
750-
response.headers = {"location": "https://example.com"}
750+
# Redirect target whose host differs from the default ingestion host
751+
# (`dc.services.visualstudio.com`) only in the leftmost DNS label, with
752+
# the same number of labels and a 3-label shared suffix, so the
753+
# cross-origin redirect guard permits it.
754+
response.headers = {"location": "https://westus.services.visualstudio.com"}
751755
prev_redirects = self._base.client._config.redirect_policy.max_redirects
752756
self._base.client._config.redirect_policy.max_redirects = 2
753757
prev_host = self._base.client._config.host
@@ -757,10 +761,57 @@ def test_transmit_http_error_redirect(self):
757761
result = self._base._transmit(self._envelopes_to_export)
758762
self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE)
759763
self.assertEqual(post.call_count, 2)
760-
self.assertEqual(self._base.client._config.host, "https://example.com")
764+
self.assertEqual(
765+
self._base.client._config.host,
766+
"https://westus.services.visualstudio.com",
767+
)
761768
self._base.client._config.redirect_policy.max_redirects = prev_redirects
762769
self._base.client._config.host = prev_host
763770

771+
def test_transmit_http_error_redirect_refuses_cross_origin(self):
772+
"""A redirect to a different registered domain must be refused so the
773+
auth policy does not attach a bearer token for a foreign host on the
774+
recursive _transmit call."""
775+
response = HttpResponse(None, None)
776+
response.status_code = 307
777+
response.headers = {"location": "https://attacker.example.com"}
778+
prev_host = self._base.client._config.host
779+
error = HttpResponseError(response=response)
780+
with mock.patch.object(AzureMonitorClient, "track") as post:
781+
post.side_effect = error
782+
result = self._base._transmit(self._envelopes_to_export)
783+
self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE)
784+
self.assertEqual(post.call_count, 1)
785+
self.assertEqual(self._base.client._config.host, prev_host)
786+
787+
def test_is_same_registered_domain(self):
788+
same = self._base._is_same_registered_domain
789+
# Same host (exact match) is always safe, even when not under a
790+
# trusted ingestion suffix (e.g. customer-configured custom host).
791+
self.assertTrue(same("westus-0.in.applicationinsights.azure.com", "westus-0.in.applicationinsights.azure.com"))
792+
self.assertTrue(same("custom-ingestion.example.invalid", "custom-ingestion.example.invalid"))
793+
# Both hosts under the same trusted Azure Monitor ingestion suffix
794+
# are permitted -- this is the cross-region case.
795+
self.assertTrue(same("westus-0.in.applicationinsights.azure.com", "eastus-0.in.applicationinsights.azure.com"))
796+
self.assertTrue(same("dc.services.visualstudio.com", "westus.services.visualstudio.com"))
797+
self.assertTrue(same("foo.applicationinsights.azure.us", "bar.applicationinsights.azure.us"))
798+
# Different registered domain entirely is rejected.
799+
self.assertFalse(same("westus-0.in.applicationinsights.azure.com", "attacker.com"))
800+
self.assertFalse(same("foo.example.com", "foo.example.org"))
801+
# Sibling subdomains under an untrusted parent are rejected -- this
802+
# is the cross-origin-leak PoC scenario.
803+
self.assertFalse(same("legit-ingestion.example.invalid", "attacker.example.invalid"))
804+
self.assertFalse(same("foo.azure.com", "bar.azure.com"))
805+
# A trusted host cannot be redirected to a host under an untrusted
806+
# suffix (and vice versa).
807+
self.assertFalse(same("dc.services.visualstudio.com", "attacker.example.invalid"))
808+
self.assertFalse(same("legit-ingestion.example.invalid", "dc.services.visualstudio.com"))
809+
# Mixing two different trusted suffixes is rejected.
810+
self.assertFalse(same("dc.services.visualstudio.com", "westus-0.in.applicationinsights.azure.com"))
811+
# Empty inputs are treated as not-same.
812+
self.assertFalse(same("", "applicationinsights.azure.com"))
813+
self.assertFalse(same("applicationinsights.azure.com", ""))
814+
764815
def test_transmit_http_error_redirect_missing_headers(self):
765816
response = HttpResponse(None, None)
766817
response.status_code = 307

sdk/monitor/azure-monitor-opentelemetry-exporter/tests/test_rate_limiter.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,10 @@ def test_redirect_does_not_double_consume_rate_limiter(self):
262262

263263
# First call raises a 307 redirect, second call succeeds
264264
mock_response = mock.Mock()
265-
mock_response.headers = {"location": "https://redirected.example.com/v2/track"}
265+
# Redirect target differing from the default ingestion host
266+
# (`dc.services.visualstudio.com`) only in the leftmost DNS label,
267+
# so the cross-origin redirect guard permits it.
268+
mock_response.headers = {"location": "https://westus.services.visualstudio.com/v2/track"}
266269
redirect_error = HttpResponseError(
267270
message="Temporary Redirect",
268271
response=mock_response,

0 commit comments

Comments
 (0)