Skip to content

Commit 924e2dc

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 c498602 commit 924e2dc

2 files changed

Lines changed: 179 additions & 1 deletion

File tree

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

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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
@@ -10,6 +11,39 @@
1011
from azure.monitor.opentelemetry.exporter._quickpulse._constants import _QUICKPULSE_REDIRECT_HEADER_NAME
1112
from azure.monitor.opentelemetry.exporter._quickpulse._generated.livemetrics import LiveMetricsClient
1213

14+
_logger = logging.getLogger(__name__)
15+
16+
# Allowed domain suffixes for QuickPulse redirect targets.
17+
# Only redirects to these trusted Azure Monitor domains are accepted.
18+
_ALLOWED_REDIRECT_DOMAIN_SUFFIXES = (
19+
".livediagnostics.monitor.azure.com",
20+
".monitor.azure.com",
21+
".services.visualstudio.com",
22+
".applicationinsights.azure.com",
23+
".monitor.azure.us",
24+
".applicationinsights.azure.us",
25+
".monitor.azure.cn",
26+
".applicationinsights.azure.cn",
27+
)
28+
29+
30+
def _is_redirect_target_allowed(netloc: str) -> bool:
31+
"""Validate that the redirect target host belongs to a known Azure Monitor domain.
32+
33+
:param str netloc: The network location (host:port) from the parsed redirect URL.
34+
:return: True if the host is in an allowed Azure Monitor domain, False otherwise.
35+
:rtype: bool
36+
"""
37+
# Use urlparse to safely extract the hostname, which handles port stripping
38+
# and detects userinfo (username/password) that could be used to spoof the host.
39+
parsed = urlparse(f"//{netloc}")
40+
if parsed.username is not None or parsed.password is not None:
41+
return False
42+
host = parsed.hostname
43+
if host is None:
44+
return False
45+
return any(host.endswith(suffix) for suffix in _ALLOWED_REDIRECT_DOMAIN_SUFFIXES)
46+
1347

1448
# Quickpulse endpoint handles redirects via header instead of status codes
1549
# We use a custom RedirectPolicy to handle this use case
@@ -27,6 +61,19 @@ def get_redirect_location(self, response: PipelineResponse) -> Optional[str]:
2761
if redirect_location:
2862
redirected_url = urlparse(redirect_location)
2963
if redirected_url.scheme and redirected_url.netloc:
64+
# Only allow HTTPS redirects to trusted Azure Monitor domains
65+
if redirected_url.scheme.lower() != "https":
66+
_logger.warning(
67+
"QuickPulse redirect rejected: non-HTTPS scheme '%s' in redirect target.",
68+
redirected_url.scheme,
69+
)
70+
return None
71+
if not _is_redirect_target_allowed(redirected_url.netloc):
72+
_logger.warning(
73+
"QuickPulse redirect rejected: host '%s' is not in the allowed domain list.",
74+
redirected_url.netloc,
75+
)
76+
return None
3077
if self._qp_client_ref:
3178
qp_client = self._qp_client_ref()
3279
if qp_client and qp_client._client:

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

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

0 commit comments

Comments
 (0)