Skip to content

Commit 2bfbf26

Browse files
committed
fix: Validate QuickPulse redirect targets against trusted Azure domains
The QuickPulse redirect policy previously accepted any Location header value and updated _base_url without validation. This allowed potential redirect poisoning where an attacker with network-level access could redirect all LiveMetrics traffic (including auth headers and telemetry) to an attacker-controlled host. This fix: - Enforces HTTPS-only scheme on redirect targets (no protocol downgrade) - Validates redirect host against an allowlist of trusted Azure Monitor domain suffixes (.monitor.azure.com, .services.visualstudio.com, .applicationinsights.azure.com, and sovereign cloud variants) - Uses urlparse hostname extraction to prevent userinfo (@) bypass attacks - Rejects redirects containing username/password in the URL - Returns None (rejecting the redirect) for untrusted targets - Logs a warning when a redirect is rejected for observability - Adds comprehensive unit tests for validation logic including userinfo bypass, domain spoofing, HTTP downgrade, and trusted domain acceptance
1 parent 41ea3dd commit 2bfbf26

2 files changed

Lines changed: 188 additions & 4 deletions

File tree

  • sdk/monitor/azure-monitor-opentelemetry-exporter

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

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,52 @@
11
# Copyright (c) Microsoft Corporation. All rights reserved.
22
# Licensed under the MIT License.
33

4+
import logging
45
from typing import Any, Optional
56
from urllib.parse import urlparse
67
from weakref import ReferenceType
78

89
from azure.core.pipeline import PipelineResponse, policies
910

10-
from azure.monitor.opentelemetry.exporter._quickpulse._constants import _QUICKPULSE_REDIRECT_HEADER_NAME
11-
from azure.monitor.opentelemetry.exporter._quickpulse._generated.livemetrics import LiveMetricsClient
11+
from azure.monitor.opentelemetry.exporter._quickpulse._constants import (
12+
_QUICKPULSE_REDIRECT_HEADER_NAME,
13+
)
14+
from azure.monitor.opentelemetry.exporter._quickpulse._generated.livemetrics import (
15+
LiveMetricsClient,
16+
)
17+
18+
_logger = logging.getLogger(__name__)
19+
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+
33+
34+
def _is_redirect_target_allowed(netloc: str) -> bool:
35+
"""Validate that the redirect target host belongs to a known Azure Monitor domain.
36+
37+
:param str netloc: The network location (host:port) from the parsed redirect URL.
38+
:return: True if the host is in an allowed Azure Monitor domain, False otherwise.
39+
:rtype: bool
40+
"""
41+
# Use urlparse to safely extract the hostname, which handles port stripping
42+
# and detects userinfo (username/password) that could be used to spoof the host.
43+
parsed = urlparse(f"//{netloc}")
44+
if parsed.username is not None or parsed.password is not None:
45+
return False
46+
host = parsed.hostname
47+
if host is None:
48+
return False
49+
return any(host.endswith(suffix) for suffix in _ALLOWED_REDIRECT_DOMAIN_SUFFIXES)
1250

1351

1452
# Quickpulse endpoint handles redirects via header instead of status codes
@@ -27,6 +65,19 @@ def get_redirect_location(self, response: PipelineResponse) -> Optional[str]:
2765
if redirect_location:
2866
redirected_url = urlparse(redirect_location)
2967
if redirected_url.scheme and redirected_url.netloc:
68+
# Only allow HTTPS redirects to trusted Azure Monitor domains
69+
if redirected_url.scheme.lower() != "https":
70+
_logger.warning(
71+
"QuickPulse redirect rejected: non-HTTPS scheme '%s' in redirect target.",
72+
redirected_url.scheme,
73+
)
74+
return None
75+
if not _is_redirect_target_allowed(redirected_url.netloc):
76+
_logger.warning(
77+
"QuickPulse redirect rejected: host '%s' is not in the allowed domain list.",
78+
redirected_url.netloc,
79+
)
80+
return None
3081
if self._qp_client_ref:
3182
qp_client = self._qp_client_ref()
3283
if qp_client and qp_client._client:

sdk/monitor/azure-monitor-opentelemetry-exporter/tests/quickpulse/test_policy.py

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
import unittest
55
from unittest import mock
66

7-
from azure.monitor.opentelemetry.exporter._quickpulse._policy import _QuickpulseRedirectPolicy
7+
from azure.monitor.opentelemetry.exporter._quickpulse._policy import (
8+
_QuickpulseRedirectPolicy,
9+
_is_redirect_target_allowed,
10+
)
811

912

1013
# pylint: disable=line-too-long
@@ -74,5 +77,135 @@ def test_get_redirect_location_no_client(self):
7477
pipeline_resp_mock.http_response = http_resp_mock
7578
policy = _QuickpulseRedirectPolicy()
7679
redirect_location = policy.get_redirect_location(pipeline_resp_mock)
77-
self.assertEqual(redirect_location, "https://eastus.livediagnostics.monitor.azure.com/QuickPulseService.svc")
80+
self.assertEqual(
81+
redirect_location,
82+
"https://eastus.livediagnostics.monitor.azure.com/QuickPulseService.svc",
83+
)
7884
self.assertIsNone(policy._qp_client_ref)
85+
86+
def test_get_redirect_location_rejects_untrusted_host(self):
87+
policy = _QuickpulseRedirectPolicy()
88+
pipeline_resp_mock = mock.Mock()
89+
http_resp_mock = mock.Mock()
90+
headers = {"x-ms-qps-service-endpoint-redirect-v2": "https://evil.attacker.com/exfiltrate"}
91+
http_resp_mock.headers = headers
92+
pipeline_resp_mock.http_response = http_resp_mock
93+
qp_client_mock = mock.Mock()
94+
client_mock = mock.Mock()
95+
client_mock._base_url = "https://original.livediagnostics.monitor.azure.com"
96+
qp_client_mock._client = client_mock
97+
qp_client_ref = weakref.ref(qp_client_mock)
98+
policy._qp_client_ref = qp_client_ref
99+
redirect_location = policy.get_redirect_location(pipeline_resp_mock)
100+
# Redirect should be rejected and return None
101+
self.assertIsNone(redirect_location)
102+
# Base URL must not be changed
103+
self.assertEqual(client_mock._base_url, "https://original.livediagnostics.monitor.azure.com")
104+
105+
def test_get_redirect_location_rejects_http_scheme(self):
106+
policy = _QuickpulseRedirectPolicy()
107+
pipeline_resp_mock = mock.Mock()
108+
http_resp_mock = mock.Mock()
109+
headers = {
110+
"x-ms-qps-service-endpoint-redirect-v2": "http://eastus.livediagnostics.monitor.azure.com/QuickPulseService.svc"
111+
}
112+
http_resp_mock.headers = headers
113+
pipeline_resp_mock.http_response = http_resp_mock
114+
qp_client_mock = mock.Mock()
115+
client_mock = mock.Mock()
116+
client_mock._base_url = "https://original.livediagnostics.monitor.azure.com"
117+
qp_client_mock._client = client_mock
118+
qp_client_ref = weakref.ref(qp_client_mock)
119+
policy._qp_client_ref = qp_client_ref
120+
redirect_location = policy.get_redirect_location(pipeline_resp_mock)
121+
# HTTP downgrade should be rejected
122+
self.assertIsNone(redirect_location)
123+
self.assertEqual(client_mock._base_url, "https://original.livediagnostics.monitor.azure.com")
124+
125+
def test_get_redirect_location_allows_visualstudio_domain(self):
126+
policy = _QuickpulseRedirectPolicy()
127+
pipeline_resp_mock = mock.Mock()
128+
http_resp_mock = mock.Mock()
129+
headers = {
130+
"x-ms-qps-service-endpoint-redirect-v2": "https://rt.services.visualstudio.com/QuickPulseService.svc"
131+
}
132+
http_resp_mock.headers = headers
133+
pipeline_resp_mock.http_response = http_resp_mock
134+
qp_client_mock = mock.Mock()
135+
client_mock = mock.Mock()
136+
client_mock._base_url = "https://original.livediagnostics.monitor.azure.com"
137+
qp_client_mock._client = client_mock
138+
qp_client_ref = weakref.ref(qp_client_mock)
139+
policy._qp_client_ref = qp_client_ref
140+
redirect_location = policy.get_redirect_location(pipeline_resp_mock)
141+
self.assertEqual(
142+
redirect_location,
143+
"https://rt.services.visualstudio.com/QuickPulseService.svc",
144+
)
145+
self.assertEqual(client_mock._base_url, "https://rt.services.visualstudio.com")
146+
147+
def test_get_redirect_location_rejects_spoofed_suffix(self):
148+
"""Attacker uses a domain that contains an allowed suffix but is not actually that domain."""
149+
policy = _QuickpulseRedirectPolicy()
150+
pipeline_resp_mock = mock.Mock()
151+
http_resp_mock = mock.Mock()
152+
# Reject a host like "monitor.azure.com.evil.com": it starts with the allowed-looking
153+
# "monitor.azure.com" string, but the actual hostname is a subdomain of "evil.com".
154+
headers = {"x-ms-qps-service-endpoint-redirect-v2": "https://monitor.azure.com.evil.com/exfiltrate"}
155+
http_resp_mock.headers = headers
156+
pipeline_resp_mock.http_response = http_resp_mock
157+
qp_client_mock = mock.Mock()
158+
client_mock = mock.Mock()
159+
client_mock._base_url = "https://original.livediagnostics.monitor.azure.com"
160+
qp_client_mock._client = client_mock
161+
qp_client_ref = weakref.ref(qp_client_mock)
162+
policy._qp_client_ref = qp_client_ref
163+
redirect_location = policy.get_redirect_location(pipeline_resp_mock)
164+
self.assertIsNone(redirect_location)
165+
self.assertEqual(client_mock._base_url, "https://original.livediagnostics.monitor.azure.com")
166+
167+
def test_get_redirect_location_rejects_userinfo_bypass(self):
168+
"""Reject redirect URLs that use userinfo (@) to disguise the real host."""
169+
policy = _QuickpulseRedirectPolicy()
170+
pipeline_resp_mock = mock.Mock()
171+
http_resp_mock = mock.Mock()
172+
# URL with userinfo: urlparse sees "evil.com" as the real host, not the allowed domain.
173+
headers = {
174+
"x-ms-qps-service-endpoint-redirect-v2": "https://eastus.livediagnostics.monitor.azure.com:443@evil.com/exfiltrate"
175+
}
176+
http_resp_mock.headers = headers
177+
pipeline_resp_mock.http_response = http_resp_mock
178+
qp_client_mock = mock.Mock()
179+
client_mock = mock.Mock()
180+
client_mock._base_url = "https://original.livediagnostics.monitor.azure.com"
181+
qp_client_mock._client = client_mock
182+
qp_client_ref = weakref.ref(qp_client_mock)
183+
policy._qp_client_ref = qp_client_ref
184+
redirect_location = policy.get_redirect_location(pipeline_resp_mock)
185+
self.assertIsNone(redirect_location)
186+
self.assertEqual(client_mock._base_url, "https://original.livediagnostics.monitor.azure.com")
187+
188+
189+
class TestIsRedirectTargetAllowed(unittest.TestCase):
190+
def test_allowed_domains(self):
191+
self.assertTrue(_is_redirect_target_allowed("eastus.livediagnostics.monitor.azure.com"))
192+
self.assertTrue(_is_redirect_target_allowed("global.livediagnostics.monitor.azure.com"))
193+
self.assertTrue(_is_redirect_target_allowed("rt.services.visualstudio.com"))
194+
self.assertTrue(_is_redirect_target_allowed("westus.in.applicationinsights.azure.com"))
195+
self.assertTrue(_is_redirect_target_allowed("settings.monitor.azure.com"))
196+
self.assertTrue(_is_redirect_target_allowed("eastus.monitor.azure.us"))
197+
self.assertTrue(_is_redirect_target_allowed("eastus.monitor.azure.cn"))
198+
199+
def test_allowed_domains_with_port(self):
200+
self.assertTrue(_is_redirect_target_allowed("eastus.livediagnostics.monitor.azure.com:443"))
201+
202+
def test_disallowed_domains(self):
203+
self.assertFalse(_is_redirect_target_allowed("evil.attacker.com"))
204+
self.assertFalse(_is_redirect_target_allowed("monitor.azure.com.evil.com"))
205+
self.assertFalse(_is_redirect_target_allowed("localhost"))
206+
self.assertFalse(_is_redirect_target_allowed("192.168.1.1"))
207+
self.assertFalse(_is_redirect_target_allowed("attacker.com"))
208+
209+
def test_disallowed_userinfo_bypass(self):
210+
self.assertFalse(_is_redirect_target_allowed("eastus.livediagnostics.monitor.azure.com:443@evil.com"))
211+
self.assertFalse(_is_redirect_target_allowed("user:pass@evil.com"))

0 commit comments

Comments
 (0)