Skip to content

Commit 8618709

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 8618709

2 files changed

Lines changed: 218 additions & 6 deletions

File tree

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

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

Lines changed: 59 additions & 4 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
@@ -22,14 +60,31 @@ def __init__(self, **kwargs: Any) -> None:
2260

2361
# Gets the redirect location from header
2462
def get_redirect_location(self, response: PipelineResponse) -> Optional[str]:
25-
redirect_location = response.http_response.headers.get(_QUICKPULSE_REDIRECT_HEADER_NAME)
63+
redirect_location = response.http_response.headers.get(
64+
_QUICKPULSE_REDIRECT_HEADER_NAME
65+
)
2666
qp_client = None
2767
if redirect_location:
2868
redirected_url = urlparse(redirect_location)
2969
if redirected_url.scheme and redirected_url.netloc:
70+
# Only allow HTTPS redirects to trusted Azure Monitor domains
71+
if redirected_url.scheme.lower() != "https":
72+
_logger.warning(
73+
"QuickPulse redirect rejected: non-HTTPS scheme '%s' in redirect target.",
74+
redirected_url.scheme,
75+
)
76+
return None
77+
if not _is_redirect_target_allowed(redirected_url.netloc):
78+
_logger.warning(
79+
"QuickPulse redirect rejected: host '%s' is not in the allowed domain list.",
80+
redirected_url.netloc,
81+
)
82+
return None
3083
if self._qp_client_ref:
3184
qp_client = self._qp_client_ref()
3285
if qp_client and qp_client._client:
3386
# Set new endpoint to redirect location
34-
qp_client._client._base_url = f"{redirected_url.scheme}://{redirected_url.netloc}"
87+
qp_client._client._base_url = (
88+
f"{redirected_url.scheme}://{redirected_url.netloc}"
89+
)
3590
return redirect_location # type: ignore

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

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

0 commit comments

Comments
 (0)