From 1d47b2851cb44f4c604a54f14c0a40235355a126 Mon Sep 17 00:00:00 2001 From: Sean Li Date: Tue, 11 Aug 2026 16:13:17 -0700 Subject: [PATCH 1/6] fix PoC A --- .../CHANGELOG.md | 2 + .../quickpulse/QuickPulseCoordinator.java | 10 ++-- .../QuickPulseRedirectValidator.java | 54 ++++++++++++++++++ .../quickpulse/QuickPulseCoordinatorTest.java | 55 +++++++++++++++++++ 4 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/CHANGELOG.md b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/CHANGELOG.md index 237ba0f7ce2b..1c1b39b8c51f 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/CHANGELOG.md +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/CHANGELOG.md @@ -8,6 +8,8 @@ ### Bugs Fixed +- Validate Live Metrics redirect targets before following them. + ### Other Changes ## 1.5.0 (2026-06-11) diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseCoordinator.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseCoordinator.java index 2c45364b1c34..0960979209b2 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseCoordinator.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseCoordinator.java @@ -10,7 +10,6 @@ import reactor.util.annotation.Nullable; import java.net.MalformedURLException; -import java.net.URL; import java.util.concurrent.TimeUnit; import static com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.AzureMonitorMsgId.QUICK_PULSE_PING_ERROR; @@ -150,17 +149,16 @@ private long ping() { return 0; } - private QuickPulseStatus handleReceivedPingHeaders(IsSubscribedHeaders pingHeaders) { + QuickPulseStatus handleReceivedPingHeaders(IsSubscribedHeaders pingHeaders) { String redirectLink = pingHeaders.getXMsQpsServiceEndpointRedirectV2(); if (!Strings.isNullOrEmpty(redirectLink)) { try { - URL redirectUrl = new URL(redirectLink); - // Taking the QuickPulseService.svc part out if present because the swagger will add that on. - qpsServiceRedirectedEndpoint = redirectUrl.getProtocol() + "://" + redirectUrl.getHost() + "/"; + qpsServiceRedirectedEndpoint = QuickPulseRedirectValidator + .validateAndGetEndpointPrefix(pingSender.getQuickPulseEndpoint(), redirectLink); logger.verbose("Handling ping header to redirect to {}", qpsServiceRedirectedEndpoint); dataSender.setRedirectEndpointPrefix(qpsServiceRedirectedEndpoint); } catch (MalformedURLException e) { - logger.error("The service returned a malformed URL in the redirect header: {}. Exception message: {}", + logger.error("The service returned an invalid URL in the redirect header: {}. Exception message: {}", redirectLink, e.getMessage()); } } diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java new file mode 100644 index 000000000000..f78bb33b43ff --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.monitor.opentelemetry.autoconfigure.implementation.quickpulse; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Locale; + +final class QuickPulseRedirectValidator { + + private static final String HTTPS = "https"; + + private QuickPulseRedirectValidator() { + } + + static String validateAndGetEndpointPrefix(String configuredEndpoint, String redirectLink) + throws MalformedURLException { + URL configuredUrl = new URL(configuredEndpoint); + URL redirectUrl = new URL(redirectLink); + + if (!HTTPS.equalsIgnoreCase(redirectUrl.getProtocol()) || redirectUrl.getUserInfo() != null) { + throw new MalformedURLException("Redirect must use https and must not contain user information"); + } + + String configuredHost = configuredUrl.getHost(); + String redirectHost = redirectUrl.getHost(); + if (!isSameOrSubdomain(redirectHost, configuredHost) && !isKnownLiveMetricsHost(redirectHost)) { + throw new MalformedURLException("Redirect host is outside the configured Live Metrics endpoint boundary"); + } + + return redirectUrl.getProtocol() + "://" + redirectUrl.getAuthority() + "/"; + } + + private static boolean isSameOrSubdomain(String host, String expectedDomain) { + String normalizedHost = normalizeHost(host); + String normalizedDomain = normalizeHost(expectedDomain); + return normalizedHost.equals(normalizedDomain) || normalizedHost.endsWith("." + normalizedDomain); + } + + private static boolean isKnownLiveMetricsHost(String host) { + String normalizedHost = normalizeHost(host); + return normalizedHost.endsWith(".services.visualstudio.com") + || normalizedHost.endsWith(".livediagnostics.monitor.azure.com") + || normalizedHost.endsWith(".applicationinsights.azure.com") + || normalizedHost.endsWith(".applicationinsights.azure.cn") + || normalizedHost.endsWith(".applicationinsights.us") + || normalizedHost.endsWith(".applicationinsights.azure.us"); + } + + private static String normalizeHost(String host) { + return host.toLowerCase(Locale.ROOT); + } +} diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseCoordinatorTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseCoordinatorTest.java index d08da0bdc927..ef16ba52507e 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseCoordinatorTest.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseCoordinatorTest.java @@ -16,6 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; class QuickPulseCoordinatorTest { private static final long VERIFY_TIMEOUT_MILLIS = 10000; @@ -120,6 +121,60 @@ private static void stopAndJoin(QuickPulseCoordinator coordinator, Thread thread assertThat(thread.isAlive()).isFalse(); } + @Test + void acceptsSameLiveMetricsDomainRedirect() { + QuickPulseDataSender mockSender = Mockito.mock(QuickPulseDataSender.class); + QuickPulsePingSender mockPingSender = Mockito.mock(QuickPulsePingSender.class); + Mockito.doReturn("https://westus.livediagnostics.monitor.azure.com/") + .when(mockPingSender) + .getQuickPulseEndpoint(); + + QuickPulseCoordinator coordinator = createCoordinator(mockSender, mockPingSender); + + HttpHeaders rawPingHeaders = new HttpHeaders(); + rawPingHeaders.add(QPS_STATUS_HEADER, "true"); + rawPingHeaders.add(QPS_SERVICE_ENDPOINT_REDIRECT, + "https://eastus.livediagnostics.monitor.azure.com/QuickPulseService.svc/"); + + assertThat(coordinator.handleReceivedPingHeaders(new IsSubscribedHeaders(rawPingHeaders))) + .isEqualTo(QuickPulseStatus.QP_IS_ON); + verify(mockSender).setRedirectEndpointPrefix("https://eastus.livediagnostics.monitor.azure.com/"); + } + + @Test + void rejectsCrossOriginRedirect() { + QuickPulseDataSender mockSender = Mockito.mock(QuickPulseDataSender.class); + QuickPulsePingSender mockPingSender = Mockito.mock(QuickPulsePingSender.class); + Mockito.doReturn("https://westus.livediagnostics.monitor.azure.com/") + .when(mockPingSender) + .getQuickPulseEndpoint(); + + QuickPulseCoordinator coordinator = createCoordinator(mockSender, mockPingSender); + + HttpHeaders rawPingHeaders = new HttpHeaders(); + rawPingHeaders.add(QPS_STATUS_HEADER, "true"); + rawPingHeaders.add(QPS_SERVICE_ENDPOINT_REDIRECT, "https://attacker.invalid/QuickPulseService.svc/"); + + assertThat(coordinator.handleReceivedPingHeaders(new IsSubscribedHeaders(rawPingHeaders))) + .isEqualTo(QuickPulseStatus.QP_IS_ON); + Mockito.verify(mockSender, Mockito.never()).setRedirectEndpointPrefix(any()); + } + + private static QuickPulseCoordinator createCoordinator(QuickPulseDataSender mockSender, + QuickPulsePingSender mockPingSender) { + AtomicReference configuration = new AtomicReference<>(new FilteringConfiguration()); + QuickPulseCoordinatorInitData initData + = new QuickPulseCoordinatorInitDataBuilder().withDataFetcher(mock(QuickPulseDataFetcher.class)) + .withDataSender(mockSender) + .withPingSender(mockPingSender) + .withCollector(new QuickPulseDataCollector(configuration)) + .withWaitBetweenPingsInMillis(10L) + .withWaitBetweenPostsInMillis(10L) + .withWaitOnErrorInMillis(10L) + .build(); + return new QuickPulseCoordinator(initData); + } + @Disabled("sporadically failing on CI") @Test void testOnePingAndThenOnePostWithRedirectedLink() throws InterruptedException { From 1e391c5403f179a07681e6180c97706ce96d2cdf Mon Sep 17 00:00:00 2001 From: Sean Li Date: Wed, 12 Aug 2026 05:34:00 -0700 Subject: [PATCH 2/6] additional check on domain --- .../QuickPulseRedirectValidator.java | 26 ++++++++--- .../quickpulse/QuickPulseCoordinatorTest.java | 45 +++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java index f78bb33b43ff..528d321989ed 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java @@ -10,6 +10,7 @@ final class QuickPulseRedirectValidator { private static final String HTTPS = "https"; + private static final int DEFAULT_HTTPS_PORT = 443; private QuickPulseRedirectValidator() { } @@ -19,23 +20,30 @@ static String validateAndGetEndpointPrefix(String configuredEndpoint, String red URL configuredUrl = new URL(configuredEndpoint); URL redirectUrl = new URL(redirectLink); - if (!HTTPS.equalsIgnoreCase(redirectUrl.getProtocol()) || redirectUrl.getUserInfo() != null) { - throw new MalformedURLException("Redirect must use https and must not contain user information"); + if (!HTTPS.equalsIgnoreCase(redirectUrl.getProtocol()) + || redirectUrl.getUserInfo() != null + || !isDefaultHttpsPort(redirectUrl)) { + throw new MalformedURLException( + "Redirect must use https, must not contain user information, and must use the default https port"); } String configuredHost = configuredUrl.getHost(); String redirectHost = redirectUrl.getHost(); - if (!isSameOrSubdomain(redirectHost, configuredHost) && !isKnownLiveMetricsHost(redirectHost)) { + if (!isSameHost(redirectHost, configuredHost) && !isKnownLiveMetricsHost(redirectHost)) { throw new MalformedURLException("Redirect host is outside the configured Live Metrics endpoint boundary"); } return redirectUrl.getProtocol() + "://" + redirectUrl.getAuthority() + "/"; } - private static boolean isSameOrSubdomain(String host, String expectedDomain) { + private static boolean isDefaultHttpsPort(URL url) { + return url.getPort() == -1 || url.getPort() == DEFAULT_HTTPS_PORT; + } + + private static boolean isSameHost(String host, String expectedHost) { String normalizedHost = normalizeHost(host); - String normalizedDomain = normalizeHost(expectedDomain); - return normalizedHost.equals(normalizedDomain) || normalizedHost.endsWith("." + normalizedDomain); + String normalizedExpectedHost = normalizeHost(expectedHost); + return normalizedHost.equals(normalizedExpectedHost); } private static boolean isKnownLiveMetricsHost(String host) { @@ -49,6 +57,10 @@ private static boolean isKnownLiveMetricsHost(String host) { } private static String normalizeHost(String host) { - return host.toLowerCase(Locale.ROOT); + String normalizedHost = host.toLowerCase(Locale.ROOT); + if (normalizedHost.endsWith(".")) { + return normalizedHost.substring(0, normalizedHost.length() - 1); + } + return normalizedHost; } } diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseCoordinatorTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseCoordinatorTest.java index ef16ba52507e..bcac94fa53ba 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseCoordinatorTest.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseCoordinatorTest.java @@ -141,6 +141,23 @@ void acceptsSameLiveMetricsDomainRedirect() { verify(mockSender).setRedirectEndpointPrefix("https://eastus.livediagnostics.monitor.azure.com/"); } + @Test + void acceptsSameHostRedirect() { + QuickPulseDataSender mockSender = Mockito.mock(QuickPulseDataSender.class); + QuickPulsePingSender mockPingSender = Mockito.mock(QuickPulsePingSender.class); + Mockito.doReturn("https://live.example.com/").when(mockPingSender).getQuickPulseEndpoint(); + + QuickPulseCoordinator coordinator = createCoordinator(mockSender, mockPingSender); + + HttpHeaders rawPingHeaders = new HttpHeaders(); + rawPingHeaders.add(QPS_STATUS_HEADER, "true"); + rawPingHeaders.add(QPS_SERVICE_ENDPOINT_REDIRECT, "https://live.example.com/QuickPulseService.svc/"); + + assertThat(coordinator.handleReceivedPingHeaders(new IsSubscribedHeaders(rawPingHeaders))) + .isEqualTo(QuickPulseStatus.QP_IS_ON); + verify(mockSender).setRedirectEndpointPrefix("https://live.example.com/"); + } + @Test void rejectsCrossOriginRedirect() { QuickPulseDataSender mockSender = Mockito.mock(QuickPulseDataSender.class); @@ -160,6 +177,34 @@ void rejectsCrossOriginRedirect() { Mockito.verify(mockSender, Mockito.never()).setRedirectEndpointPrefix(any()); } + @Test + void rejectsInvalidRedirects() { + assertRedirectRejected("http://eastus.livediagnostics.monitor.azure.com/QuickPulseService.svc/"); + assertRedirectRejected("https://user@eastus.livediagnostics.monitor.azure.com/QuickPulseService.svc/"); + assertRedirectRejected("https://eastus.livediagnostics.monitor.azure.com:444/QuickPulseService.svc/"); + assertRedirectRejected( + "https://evil.livediagnostics.monitor.azure.com.attacker.invalid/QuickPulseService.svc/"); + assertRedirectRejected("https://evil.live.example.com/QuickPulseService.svc/"); + } + + private static void assertRedirectRejected(String redirectLink) { + QuickPulseDataSender mockSender = Mockito.mock(QuickPulseDataSender.class); + QuickPulsePingSender mockPingSender = Mockito.mock(QuickPulsePingSender.class); + Mockito.doReturn("https://westus.livediagnostics.monitor.azure.com/") + .when(mockPingSender) + .getQuickPulseEndpoint(); + + QuickPulseCoordinator coordinator = createCoordinator(mockSender, mockPingSender); + + HttpHeaders rawPingHeaders = new HttpHeaders(); + rawPingHeaders.add(QPS_STATUS_HEADER, "true"); + rawPingHeaders.add(QPS_SERVICE_ENDPOINT_REDIRECT, redirectLink); + + assertThat(coordinator.handleReceivedPingHeaders(new IsSubscribedHeaders(rawPingHeaders))) + .isEqualTo(QuickPulseStatus.QP_IS_ON); + Mockito.verify(mockSender, Mockito.never()).setRedirectEndpointPrefix(any()); + } + private static QuickPulseCoordinator createCoordinator(QuickPulseDataSender mockSender, QuickPulsePingSender mockPingSender) { AtomicReference configuration = new AtomicReference<>(new FilteringConfiguration()); From bbee72829794db6c8deaef70350a034210f4b2f0 Mon Sep 17 00:00:00 2001 From: Sean Li Date: Wed, 12 Aug 2026 10:39:25 -0700 Subject: [PATCH 3/6] add PR link in changelog --- .../azure-monitor-opentelemetry-autoconfigure/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/CHANGELOG.md b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/CHANGELOG.md index 1c1b39b8c51f..ce6bfa46de47 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/CHANGELOG.md +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/CHANGELOG.md @@ -8,7 +8,7 @@ ### Bugs Fixed -- Validate Live Metrics redirect targets before following them. +- Validate Live Metrics redirect targets before following them ([#50117](https://github.com/Azure/azure-sdk-for-java/pull/50117)) ### Other Changes From 7a788b8b44cfb198583e9fe20ee0812fc03df889 Mon Sep 17 00:00:00 2001 From: Sean Li Date: Thu, 13 Aug 2026 13:13:52 -0700 Subject: [PATCH 4/6] Use universal domain list --- .../QuickPulseRedirectValidator.java | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java index 528d321989ed..bb7d4710a516 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java @@ -5,6 +5,9 @@ import java.net.MalformedURLException; import java.net.URL; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Locale; final class QuickPulseRedirectValidator { @@ -12,6 +15,11 @@ final class QuickPulseRedirectValidator { private static final String HTTPS = "https"; private static final int DEFAULT_HTTPS_PORT = 443; + private static final List ALLOWED_REDIRECT_DOMAIN_SUFFIXES + = Collections.unmodifiableList(Arrays.asList(".livediagnostics.monitor.azure.com", ".monitor.azure.com", + ".services.visualstudio.com", ".applicationinsights.azure.com", ".monitor.azure.us", + ".applicationinsights.azure.us", ".monitor.azure.cn", ".applicationinsights.azure.cn")); + private QuickPulseRedirectValidator() { } @@ -48,12 +56,12 @@ private static boolean isSameHost(String host, String expectedHost) { private static boolean isKnownLiveMetricsHost(String host) { String normalizedHost = normalizeHost(host); - return normalizedHost.endsWith(".services.visualstudio.com") - || normalizedHost.endsWith(".livediagnostics.monitor.azure.com") - || normalizedHost.endsWith(".applicationinsights.azure.com") - || normalizedHost.endsWith(".applicationinsights.azure.cn") - || normalizedHost.endsWith(".applicationinsights.us") - || normalizedHost.endsWith(".applicationinsights.azure.us"); + for (String suffix : ALLOWED_REDIRECT_DOMAIN_SUFFIXES) { + if (normalizedHost.endsWith(suffix)) { + return true; + } + } + return false; } private static String normalizeHost(String host) { From 0b3325d3fa2315be015f7d1c467ec0fee63e4722 Mon Sep 17 00:00:00 2001 From: Sean Li Date: Fri, 14 Aug 2026 02:50:25 -0700 Subject: [PATCH 5/6] validate ingestion as well --- .../CHANGELOG.md | 2 +- .../pipeline/TelemetryPipeline.java | 7 ++ .../QuickPulseRedirectValidator.java | 53 +------- .../utils/RedirectPolicyHelper.java | 118 ++++++++++++++++++ .../pipeline/TelemetryItemExporterTest.java | 32 ++++- .../utils/RedirectPolicyHelperTest.java | 108 ++++++++++++++++ 6 files changed, 264 insertions(+), 56 deletions(-) create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelper.java create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelperTest.java diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/CHANGELOG.md b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/CHANGELOG.md index 4c653acbb346..559fd407a8a1 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/CHANGELOG.md +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/CHANGELOG.md @@ -8,7 +8,7 @@ ### Bugs Fixed -- Validate Live Metrics redirect targets before following them ([#50117](https://github.com/Azure/azure-sdk-for-java/pull/50117)) +- Validate ingestion and Live Metrics redirect targets before following them ([#50117](https://github.com/Azure/azure-sdk-for-java/pull/50117)) ### Other Changes diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java index 8d5b4b7f969f..35830eb906f7 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java @@ -11,6 +11,7 @@ import com.azure.core.util.tracing.Tracer; import com.azure.monitor.opentelemetry.autoconfigure.implementation.configuration.ConnectionString; import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.TelemetryBatchMetadata; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.RedirectPolicyHelper; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.StatusCode; import io.opentelemetry.sdk.common.CompletableResultCode; import reactor.core.publisher.Mono; @@ -114,6 +115,12 @@ private void onResponseBody(TelemetryPipelineRequest request, HttpResponse respo listener.onException(request, "Invalid redirect: " + location, e); return; } + if (!RedirectPolicyHelper.isTrustedIngestionRedirect(request.getUrl(), locationUrl)) { + String errorMessage = "Refused cross-origin redirect: " + location; + listener.onException(request, errorMessage, new MalformedURLException(errorMessage)); + result.fail(); + return; + } redirectCache.put(request.getConnectionString(), locationUrl); request.setUrl(locationUrl); sendInternal(request, listener, result, remainingRedirects - 1); diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java index bb7d4710a516..7412b13948a7 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java @@ -3,23 +3,13 @@ package com.azure.monitor.opentelemetry.autoconfigure.implementation.quickpulse; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.RedirectPolicyHelper; + import java.net.MalformedURLException; import java.net.URL; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Locale; final class QuickPulseRedirectValidator { - private static final String HTTPS = "https"; - private static final int DEFAULT_HTTPS_PORT = 443; - - private static final List ALLOWED_REDIRECT_DOMAIN_SUFFIXES - = Collections.unmodifiableList(Arrays.asList(".livediagnostics.monitor.azure.com", ".monitor.azure.com", - ".services.visualstudio.com", ".applicationinsights.azure.com", ".monitor.azure.us", - ".applicationinsights.azure.us", ".monitor.azure.cn", ".applicationinsights.azure.cn")); - private QuickPulseRedirectValidator() { } @@ -28,47 +18,10 @@ static String validateAndGetEndpointPrefix(String configuredEndpoint, String red URL configuredUrl = new URL(configuredEndpoint); URL redirectUrl = new URL(redirectLink); - if (!HTTPS.equalsIgnoreCase(redirectUrl.getProtocol()) - || redirectUrl.getUserInfo() != null - || !isDefaultHttpsPort(redirectUrl)) { - throw new MalformedURLException( - "Redirect must use https, must not contain user information, and must use the default https port"); - } - - String configuredHost = configuredUrl.getHost(); - String redirectHost = redirectUrl.getHost(); - if (!isSameHost(redirectHost, configuredHost) && !isKnownLiveMetricsHost(redirectHost)) { + if (!RedirectPolicyHelper.isTrustedLiveMetricsRedirect(configuredUrl, redirectUrl)) { throw new MalformedURLException("Redirect host is outside the configured Live Metrics endpoint boundary"); } return redirectUrl.getProtocol() + "://" + redirectUrl.getAuthority() + "/"; } - - private static boolean isDefaultHttpsPort(URL url) { - return url.getPort() == -1 || url.getPort() == DEFAULT_HTTPS_PORT; - } - - private static boolean isSameHost(String host, String expectedHost) { - String normalizedHost = normalizeHost(host); - String normalizedExpectedHost = normalizeHost(expectedHost); - return normalizedHost.equals(normalizedExpectedHost); - } - - private static boolean isKnownLiveMetricsHost(String host) { - String normalizedHost = normalizeHost(host); - for (String suffix : ALLOWED_REDIRECT_DOMAIN_SUFFIXES) { - if (normalizedHost.endsWith(suffix)) { - return true; - } - } - return false; - } - - private static String normalizeHost(String host) { - String normalizedHost = host.toLowerCase(Locale.ROOT); - if (normalizedHost.endsWith(".")) { - return normalizedHost.substring(0, normalizedHost.length() - 1); - } - return normalizedHost; - } } diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelper.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelper.java new file mode 100644 index 000000000000..4e7474fe03e3 --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelper.java @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.monitor.opentelemetry.autoconfigure.implementation.utils; + +import java.net.URL; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +/** + * Trust boundary checks for server-issued redirects. Following an attacker-controlled redirect would cause the + * pipeline to attach a freshly signed credential (and the telemetry payload) to a foreign host. + */ +public final class RedirectPolicyHelper { + + private static final String HTTPS = "https"; + + private static final List ALLOWED_REDIRECT_DOMAIN_SUFFIXES + = Collections.unmodifiableList(Arrays.asList(".livediagnostics.monitor.azure.com", ".monitor.azure.com", + ".services.visualstudio.com", ".applicationinsights.azure.com", ".monitor.azure.us", + ".applicationinsights.azure.us", ".monitor.azure.cn", ".applicationinsights.azure.cn")); + + /** + * Returns whether a Live Metrics redirect target is safe to follow. + * + * @param configuredUrl the configured Live Metrics endpoint + * @param redirectUrl the redirect target from the {@code x-ms-qps-service-endpoint-redirect-v2} header + * @return true if the redirect target is trusted + */ + public static boolean isTrustedLiveMetricsRedirect(URL configuredUrl, URL redirectUrl) { + if (!isValidHttpsRedirect(redirectUrl) || !isDefaultPort(redirectUrl)) { + return false; + } + + String redirectHost = canonicalHost(redirectUrl); + if (redirectHost.isEmpty()) { + return false; + } + + // A redirect back to the configured host stays inside the boundary the customer already chose, which keeps + // custom endpoints and reverse proxies working. + return redirectHost.equals(canonicalHost(configuredUrl)) || hasAllowedSuffix(redirectHost); + } + + /** + * Returns whether an ingestion redirect target is safe to follow. + * + * @param currentUrl the URL the request is currently targeting + * @param redirectUrl the redirect target from the {@code Location} header + * @return true if the redirect target is trusted + */ + public static boolean isTrustedIngestionRedirect(URL currentUrl, URL redirectUrl) { + if (!isValidHttpsRedirect(redirectUrl)) { + return false; + } + + String currentHost = canonicalHost(currentUrl); + String redirectHost = canonicalHost(redirectUrl); + if (currentHost.isEmpty() || redirectHost.isEmpty()) { + return false; + } + + if (currentHost.equals(redirectHost)) { + return HTTPS.equalsIgnoreCase(currentUrl.getProtocol()) + && effectivePort(currentUrl) == effectivePort(redirectUrl); + } + + if (!isDefaultPort(currentUrl) || !isDefaultPort(redirectUrl)) { + return false; + } + + // Cross-host ingestion redirects are stamp reassignments, so both hosts must live under the same suffix. + for (String suffix : ALLOWED_REDIRECT_DOMAIN_SUFFIXES) { + if (currentHost.endsWith(suffix) && redirectHost.endsWith(suffix)) { + return true; + } + } + return false; + } + + private static boolean isValidHttpsRedirect(URL redirectUrl) { + return HTTPS.equalsIgnoreCase(redirectUrl.getProtocol()) && redirectUrl.getUserInfo() == null; + } + + private static boolean hasAllowedSuffix(String host) { + for (String suffix : ALLOWED_REDIRECT_DOMAIN_SUFFIXES) { + if (host.endsWith(suffix)) { + return true; + } + } + return false; + } + + private static boolean isDefaultPort(URL url) { + return url.getPort() == -1 || url.getPort() == url.getDefaultPort(); + } + + private static int effectivePort(URL url) { + return url.getPort() == -1 ? url.getDefaultPort() : url.getPort(); + } + + private static String canonicalHost(URL url) { + String host = url.getHost(); + if (host == null) { + return ""; + } + String canonicalHost = host.toLowerCase(Locale.ROOT); + while (canonicalHost.endsWith(".")) { + canonicalHost = canonicalHost.substring(0, canonicalHost.length() - 1); + } + return canonicalHost; + } + + private RedirectPolicyHelper() { + } +} diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporterTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporterTest.java index 7d11e677f0a6..f97f853360e2 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporterTest.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporterTest.java @@ -47,13 +47,14 @@ public class TelemetryItemExporterTest { - private static final String CONNECTION_STRING - = "InstrumentationKey=00000000-0000-0000-0000-0FEEDDADBEEF;IngestionEndpoint=http://foo.bar"; - private static final String REDIRECT_CONNECTION_STRING - = "InstrumentationKey=11111111-0000-0000-0000-0FEEDDADBEEF;IngestionEndpoint=http://foo.bar"; + private static final String CONNECTION_STRING = "InstrumentationKey=00000000-0000-0000-0000-0FEEDDADBEEF;" + + "IngestionEndpoint=https://westus-0.in.applicationinsights.azure.com"; + private static final String REDIRECT_CONNECTION_STRING = "InstrumentationKey=11111111-0000-0000-0000-0FEEDDADBEEF;" + + "IngestionEndpoint=https://westus-0.in.applicationinsights.azure.com"; private static final String INSTRUMENTATION_KEY = "00000000-0000-0000-0000-0FEEDDADBEEF"; - private static final String REDIRECT_URL = "http://foo.bar.redirect"; + private static final String REDIRECT_URL = "https://eastus-0.in.applicationinsights.azure.com"; + private static final String UNTRUSTED_REDIRECT_URL = "https://attacker.invalid"; RecordingHttpClient recordingHttpClient; @@ -311,6 +312,27 @@ public void groupTelemetryItemsByConnectionStringAndRoleNameTest() { assertThat(group3.get(1).getConnectionString()).isEqualTo(REDIRECT_CONNECTION_STRING); } + @Test + public void untrustedRedirectIsNotFollowedOrCachedTest() { + // given + recordingHttpClient = new RecordingHttpClient(request -> { + Map headers = new HashMap<>(); + headers.put("Location", UNTRUSTED_REDIRECT_URL); + return Mono.just(new MockHttpResponse(request, 307, new HttpHeaders(headers))); + }); + List telemetryItems = new ArrayList<>(); + telemetryItems.add(TestUtils.createMetricTelemetry("metric" + 1, 1, REDIRECT_CONNECTION_STRING)); + TelemetryItemExporter exporter = getExporter(); + + // when + exporter.send(telemetryItems); + exporter.send(telemetryItems); + + // then + // each send stops at the original endpoint instead of replaying against the untrusted host + assertThat(recordingHttpClient.getCount()).isEqualTo(2); + } + static class RecordingHttpClient implements HttpClient { private final AtomicInteger count = new AtomicInteger(); diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelperTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelperTest.java new file mode 100644 index 000000000000..cf567757c66c --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelperTest.java @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.monitor.opentelemetry.autoconfigure.implementation.utils; + +import org.junit.jupiter.api.Test; + +import java.net.MalformedURLException; +import java.net.URL; + +import static org.assertj.core.api.Assertions.assertThat; + +public class RedirectPolicyHelperTest { + + private static final String LIVE_METRICS_ENDPOINT = "https://westus.livediagnostics.monitor.azure.com/"; + private static final String INGESTION_ENDPOINT = "https://westus-0.in.applicationinsights.azure.com/v2.1/track"; + + @Test + public void liveMetricsAllowsTrustedSuffix() throws MalformedURLException { + assertThat(isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, + "https://eastus.livediagnostics.monitor.azure.com/QuickPulseService.svc/")).isTrue(); + } + + @Test + public void liveMetricsAllowsConfiguredHost() throws MalformedURLException { + assertThat(isTrustedLiveMetricsRedirect("https://live.example.com/", + "https://live.example.com/QuickPulseService.svc/")).isTrue(); + } + + @Test + public void liveMetricsIsCaseAndTrailingDotInsensitive() throws MalformedURLException { + assertThat(isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, + "https://EastUS.LiveDiagnostics.Monitor.Azure.Com./QuickPulseService.svc/")).isTrue(); + } + + @Test + public void liveMetricsRejectsUntrustedTargets() throws MalformedURLException { + assertThat(isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, "https://attacker.invalid/")).isFalse(); + assertThat(isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, + "https://evil.livediagnostics.monitor.azure.com.attacker.invalid/")).isFalse(); + assertThat(isTrustedLiveMetricsRedirect("https://live.example.com/", "https://evil.live.example.com/")) + .isFalse(); + } + + @Test + public void liveMetricsRejectsUnsafeUrls() throws MalformedURLException { + assertThat( + isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, "http://eastus.livediagnostics.monitor.azure.com/")) + .isFalse(); + assertThat(isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, + "https://user@eastus.livediagnostics.monitor.azure.com/")).isFalse(); + assertThat(isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, + "https://eastus.livediagnostics.monitor.azure.com:444/")).isFalse(); + } + + @Test + public void ingestionAllowsSharedSuffix() throws MalformedURLException { + assertThat(isTrustedIngestionRedirect(INGESTION_ENDPOINT, + "https://eastus-0.in.applicationinsights.azure.com/v2.1/track")).isTrue(); + } + + @Test + public void ingestionAllowsSameHost() throws MalformedURLException { + assertThat(isTrustedIngestionRedirect("https://ingestion.example.com/v2.1/track", + "https://ingestion.example.com/v2/track")).isTrue(); + } + + @Test + public void ingestionRejectsSameHostWithDifferentPort() throws MalformedURLException { + assertThat(isTrustedIngestionRedirect("https://ingestion.example.com/v2.1/track", + "https://ingestion.example.com:444/v2/track")).isFalse(); + } + + @Test + public void ingestionRejectsSiblingsOfUntrustedParent() throws MalformedURLException { + assertThat(isTrustedIngestionRedirect("https://ingestion.example.com/v2.1/track", + "https://attacker.example.com/v2.1/track")).isFalse(); + assertThat(isTrustedIngestionRedirect("https://foo.azure.com/v2.1/track", "https://bar.azure.com/v2.1/track")) + .isFalse(); + } + + @Test + public void ingestionRejectsCrossingBetweenTrustedSuffixes() throws MalformedURLException { + assertThat( + isTrustedIngestionRedirect(INGESTION_ENDPOINT, "https://westus.services.visualstudio.com/v2.1/track")) + .isFalse(); + } + + @Test + public void ingestionRejectsUnsafeUrls() throws MalformedURLException { + assertThat(isTrustedIngestionRedirect(INGESTION_ENDPOINT, + "http://eastus-0.in.applicationinsights.azure.com/v2.1/track")).isFalse(); + assertThat(isTrustedIngestionRedirect(INGESTION_ENDPOINT, + "https://user@eastus-0.in.applicationinsights.azure.com/v2.1/track")).isFalse(); + assertThat(isTrustedIngestionRedirect(INGESTION_ENDPOINT, + "https://eastus-0.in.applicationinsights.azure.com:444/v2.1/track")).isFalse(); + } + + private static boolean isTrustedLiveMetricsRedirect(String configuredEndpoint, String redirectLink) + throws MalformedURLException { + return RedirectPolicyHelper.isTrustedLiveMetricsRedirect(new URL(configuredEndpoint), new URL(redirectLink)); + } + + private static boolean isTrustedIngestionRedirect(String currentUrl, String redirectLink) + throws MalformedURLException { + return RedirectPolicyHelper.isTrustedIngestionRedirect(new URL(currentUrl), new URL(redirectLink)); + } +} From 4d42a7a3a4762a2ecb93d6d0ef61c50d32c1450e Mon Sep 17 00:00:00 2001 From: Sean Li Date: Fri, 14 Aug 2026 03:00:43 -0700 Subject: [PATCH 6/6] Add `.applicationinsights.microsoft.com` in the allowlist. Allow redirect in trusted boundary --- .../pipeline/TelemetryPipeline.java | 2 +- .../QuickPulseRedirectValidator.java | 2 +- .../utils/RedirectPolicyHelper.java | 70 +++--------- .../pipeline/TelemetryItemExporterTest.java | 7 +- .../utils/RedirectPolicyHelperTest.java | 106 +++++++----------- 5 files changed, 64 insertions(+), 123 deletions(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java index 35830eb906f7..049df24c50d8 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java @@ -115,7 +115,7 @@ private void onResponseBody(TelemetryPipelineRequest request, HttpResponse respo listener.onException(request, "Invalid redirect: " + location, e); return; } - if (!RedirectPolicyHelper.isTrustedIngestionRedirect(request.getUrl(), locationUrl)) { + if (!RedirectPolicyHelper.isTrustedRedirect(request.getUrl(), locationUrl)) { String errorMessage = "Refused cross-origin redirect: " + location; listener.onException(request, errorMessage, new MalformedURLException(errorMessage)); result.fail(); diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java index 7412b13948a7..a913bafa50c0 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/quickpulse/QuickPulseRedirectValidator.java @@ -18,7 +18,7 @@ static String validateAndGetEndpointPrefix(String configuredEndpoint, String red URL configuredUrl = new URL(configuredEndpoint); URL redirectUrl = new URL(redirectLink); - if (!RedirectPolicyHelper.isTrustedLiveMetricsRedirect(configuredUrl, redirectUrl)) { + if (!RedirectPolicyHelper.isTrustedRedirect(configuredUrl, redirectUrl)) { throw new MalformedURLException("Redirect host is outside the configured Live Metrics endpoint boundary"); } diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelper.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelper.java index 4e7474fe03e3..9892591ed27d 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelper.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelper.java @@ -17,20 +17,26 @@ public final class RedirectPolicyHelper { private static final String HTTPS = "https"; - private static final List ALLOWED_REDIRECT_DOMAIN_SUFFIXES - = Collections.unmodifiableList(Arrays.asList(".livediagnostics.monitor.azure.com", ".monitor.azure.com", - ".services.visualstudio.com", ".applicationinsights.azure.com", ".monitor.azure.us", + private static final List ALLOWED_REDIRECT_DOMAIN_SUFFIXES = Collections.unmodifiableList( + Arrays.asList(".livediagnostics.monitor.azure.com", ".monitor.azure.com", ".services.visualstudio.com", + ".applicationinsights.azure.com", ".applicationinsights.microsoft.com", ".monitor.azure.us", ".applicationinsights.azure.us", ".monitor.azure.cn", ".applicationinsights.azure.cn")); /** - * Returns whether a Live Metrics redirect target is safe to follow. + * Returns whether a redirect target is safe to follow. + *

+ * Stamp reassignment moves between suffixes (for example {@code rt.services.visualstudio.com} to + * {@code <region>.livediagnostics.monitor.azure.com}), so the target host is checked on its own rather than + * being required to share a suffix with the current host. * - * @param configuredUrl the configured Live Metrics endpoint - * @param redirectUrl the redirect target from the {@code x-ms-qps-service-endpoint-redirect-v2} header + * @param currentUrl the endpoint the request is currently targeting + * @param redirectUrl the redirect target * @return true if the redirect target is trusted */ - public static boolean isTrustedLiveMetricsRedirect(URL configuredUrl, URL redirectUrl) { - if (!isValidHttpsRedirect(redirectUrl) || !isDefaultPort(redirectUrl)) { + public static boolean isTrustedRedirect(URL currentUrl, URL redirectUrl) { + if (!HTTPS.equalsIgnoreCase(redirectUrl.getProtocol()) + || redirectUrl.getUserInfo() != null + || !isDefaultPort(redirectUrl)) { return false; } @@ -39,49 +45,9 @@ public static boolean isTrustedLiveMetricsRedirect(URL configuredUrl, URL redire return false; } - // A redirect back to the configured host stays inside the boundary the customer already chose, which keeps + // A redirect back to the current host stays inside the boundary the customer already chose, which keeps // custom endpoints and reverse proxies working. - return redirectHost.equals(canonicalHost(configuredUrl)) || hasAllowedSuffix(redirectHost); - } - - /** - * Returns whether an ingestion redirect target is safe to follow. - * - * @param currentUrl the URL the request is currently targeting - * @param redirectUrl the redirect target from the {@code Location} header - * @return true if the redirect target is trusted - */ - public static boolean isTrustedIngestionRedirect(URL currentUrl, URL redirectUrl) { - if (!isValidHttpsRedirect(redirectUrl)) { - return false; - } - - String currentHost = canonicalHost(currentUrl); - String redirectHost = canonicalHost(redirectUrl); - if (currentHost.isEmpty() || redirectHost.isEmpty()) { - return false; - } - - if (currentHost.equals(redirectHost)) { - return HTTPS.equalsIgnoreCase(currentUrl.getProtocol()) - && effectivePort(currentUrl) == effectivePort(redirectUrl); - } - - if (!isDefaultPort(currentUrl) || !isDefaultPort(redirectUrl)) { - return false; - } - - // Cross-host ingestion redirects are stamp reassignments, so both hosts must live under the same suffix. - for (String suffix : ALLOWED_REDIRECT_DOMAIN_SUFFIXES) { - if (currentHost.endsWith(suffix) && redirectHost.endsWith(suffix)) { - return true; - } - } - return false; - } - - private static boolean isValidHttpsRedirect(URL redirectUrl) { - return HTTPS.equalsIgnoreCase(redirectUrl.getProtocol()) && redirectUrl.getUserInfo() == null; + return redirectHost.equals(canonicalHost(currentUrl)) || hasAllowedSuffix(redirectHost); } private static boolean hasAllowedSuffix(String host) { @@ -97,10 +63,6 @@ private static boolean isDefaultPort(URL url) { return url.getPort() == -1 || url.getPort() == url.getDefaultPort(); } - private static int effectivePort(URL url) { - return url.getPort() == -1 ? url.getDefaultPort() : url.getPort(); - } - private static String canonicalHost(URL url) { String host = url.getHost(); if (host == null) { diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporterTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporterTest.java index f97f853360e2..249d06e695ca 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporterTest.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporterTest.java @@ -48,12 +48,13 @@ public class TelemetryItemExporterTest { private static final String CONNECTION_STRING = "InstrumentationKey=00000000-0000-0000-0000-0FEEDDADBEEF;" - + "IngestionEndpoint=https://westus-0.in.applicationinsights.azure.com"; + + "IngestionEndpoint=https://dc.services.visualstudio.com"; private static final String REDIRECT_CONNECTION_STRING = "InstrumentationKey=11111111-0000-0000-0000-0FEEDDADBEEF;" - + "IngestionEndpoint=https://westus-0.in.applicationinsights.azure.com"; + + "IngestionEndpoint=https://dc.services.visualstudio.com"; private static final String INSTRUMENTATION_KEY = "00000000-0000-0000-0000-0FEEDDADBEEF"; - private static final String REDIRECT_URL = "https://eastus-0.in.applicationinsights.azure.com"; + // stamp reassignment moves from the global host to a regional host under a different suffix + private static final String REDIRECT_URL = "https://westus-0.in.applicationinsights.azure.com"; private static final String UNTRUSTED_REDIRECT_URL = "https://attacker.invalid"; RecordingHttpClient recordingHttpClient; diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelperTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelperTest.java index cf567757c66c..e8a6b3cd192a 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelperTest.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/RedirectPolicyHelperTest.java @@ -12,97 +12,75 @@ public class RedirectPolicyHelperTest { - private static final String LIVE_METRICS_ENDPOINT = "https://westus.livediagnostics.monitor.azure.com/"; - private static final String INGESTION_ENDPOINT = "https://westus-0.in.applicationinsights.azure.com/v2.1/track"; + private static final String DEFAULT_LIVE_METRICS_ENDPOINT = "https://rt.services.visualstudio.com/"; + private static final String DEFAULT_INGESTION_ENDPOINT = "https://dc.services.visualstudio.com/v2.1/track"; @Test - public void liveMetricsAllowsTrustedSuffix() throws MalformedURLException { - assertThat(isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, - "https://eastus.livediagnostics.monitor.azure.com/QuickPulseService.svc/")).isTrue(); + public void allowsLiveMetricsStampRedirect() throws MalformedURLException { + assertThat(isTrustedRedirect(DEFAULT_LIVE_METRICS_ENDPOINT, + "https://westus.livediagnostics.monitor.azure.com/QuickPulseService.svc/")).isTrue(); } @Test - public void liveMetricsAllowsConfiguredHost() throws MalformedURLException { - assertThat(isTrustedLiveMetricsRedirect("https://live.example.com/", - "https://live.example.com/QuickPulseService.svc/")).isTrue(); + public void allowsIngestionStampRedirect() throws MalformedURLException { + assertThat(isTrustedRedirect(DEFAULT_INGESTION_ENDPOINT, + "https://westus-0.in.applicationinsights.azure.com/v2.1/track")).isTrue(); } @Test - public void liveMetricsIsCaseAndTrailingDotInsensitive() throws MalformedURLException { - assertThat(isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, - "https://EastUS.LiveDiagnostics.Monitor.Azure.Com./QuickPulseService.svc/")).isTrue(); - } - - @Test - public void liveMetricsRejectsUntrustedTargets() throws MalformedURLException { - assertThat(isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, "https://attacker.invalid/")).isFalse(); - assertThat(isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, - "https://evil.livediagnostics.monitor.azure.com.attacker.invalid/")).isFalse(); - assertThat(isTrustedLiveMetricsRedirect("https://live.example.com/", "https://evil.live.example.com/")) - .isFalse(); - } - - @Test - public void liveMetricsRejectsUnsafeUrls() throws MalformedURLException { + public void allowsGlobalApplicationInsightsHosts() throws MalformedURLException { assertThat( - isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, "http://eastus.livediagnostics.monitor.azure.com/")) - .isFalse(); - assertThat(isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, - "https://user@eastus.livediagnostics.monitor.azure.com/")).isFalse(); - assertThat(isTrustedLiveMetricsRedirect(LIVE_METRICS_ENDPOINT, - "https://eastus.livediagnostics.monitor.azure.com:444/")).isFalse(); + isTrustedRedirect(DEFAULT_INGESTION_ENDPOINT, "https://dc.applicationinsights.microsoft.com/v2.1/track")) + .isTrue(); + assertThat(isTrustedRedirect(DEFAULT_LIVE_METRICS_ENDPOINT, + "https://rt.applicationinsights.microsoft.com/QuickPulseService.svc/")).isTrue(); } @Test - public void ingestionAllowsSharedSuffix() throws MalformedURLException { - assertThat(isTrustedIngestionRedirect(INGESTION_ENDPOINT, - "https://eastus-0.in.applicationinsights.azure.com/v2.1/track")).isTrue(); + public void allowsSovereignCloudHosts() throws MalformedURLException { + assertThat(isTrustedRedirect("https://dc.applicationinsights.azure.us/v2.1/track", + "https://usgovvirginia.livediagnostics.monitor.azure.us/QuickPulseService.svc/")).isTrue(); + assertThat(isTrustedRedirect("https://dc.applicationinsights.azure.cn/v2.1/track", + "https://chinanorth2.in.applicationinsights.azure.cn/v2.1/track")).isTrue(); } @Test - public void ingestionAllowsSameHost() throws MalformedURLException { - assertThat(isTrustedIngestionRedirect("https://ingestion.example.com/v2.1/track", - "https://ingestion.example.com/v2/track")).isTrue(); + public void allowsCurrentHost() throws MalformedURLException { + assertThat( + isTrustedRedirect("https://ingestion.example.com/v2.1/track", "https://ingestion.example.com/v2/track")) + .isTrue(); } @Test - public void ingestionRejectsSameHostWithDifferentPort() throws MalformedURLException { - assertThat(isTrustedIngestionRedirect("https://ingestion.example.com/v2.1/track", - "https://ingestion.example.com:444/v2/track")).isFalse(); + public void isCaseAndTrailingDotInsensitive() throws MalformedURLException { + assertThat(isTrustedRedirect(DEFAULT_LIVE_METRICS_ENDPOINT, + "https://WestUS.LiveDiagnostics.Monitor.Azure.Com./QuickPulseService.svc/")).isTrue(); } @Test - public void ingestionRejectsSiblingsOfUntrustedParent() throws MalformedURLException { - assertThat(isTrustedIngestionRedirect("https://ingestion.example.com/v2.1/track", - "https://attacker.example.com/v2.1/track")).isFalse(); - assertThat(isTrustedIngestionRedirect("https://foo.azure.com/v2.1/track", "https://bar.azure.com/v2.1/track")) - .isFalse(); + public void rejectsUntrustedTargets() throws MalformedURLException { + assertThat(isTrustedRedirect(DEFAULT_INGESTION_ENDPOINT, "https://attacker.invalid/v2.1/track")).isFalse(); + assertThat(isTrustedRedirect(DEFAULT_INGESTION_ENDPOINT, + "https://evil.applicationinsights.azure.com.attacker.invalid/v2.1/track")).isFalse(); + assertThat(isTrustedRedirect("https://ingestion.example.com/v2.1/track", + "https://evil.ingestion.example.com/v2.1/track")).isFalse(); + assertThat(isTrustedRedirect("https://foo.azure.com/v2.1/track", "https://bar.azure.com/v2.1/track")).isFalse(); } @Test - public void ingestionRejectsCrossingBetweenTrustedSuffixes() throws MalformedURLException { + public void rejectsUnsafeUrls() throws MalformedURLException { + assertThat(isTrustedRedirect(DEFAULT_INGESTION_ENDPOINT, + "http://westus-0.in.applicationinsights.azure.com/v2.1/track")).isFalse(); + assertThat(isTrustedRedirect(DEFAULT_INGESTION_ENDPOINT, + "https://user@westus-0.in.applicationinsights.azure.com/v2.1/track")).isFalse(); + assertThat(isTrustedRedirect(DEFAULT_INGESTION_ENDPOINT, + "https://westus-0.in.applicationinsights.azure.com:444/v2.1/track")).isFalse(); assertThat( - isTrustedIngestionRedirect(INGESTION_ENDPOINT, "https://westus.services.visualstudio.com/v2.1/track")) + isTrustedRedirect("https://ingestion.example.com/v2.1/track", "https://ingestion.example.com:444/v2/track")) .isFalse(); } - @Test - public void ingestionRejectsUnsafeUrls() throws MalformedURLException { - assertThat(isTrustedIngestionRedirect(INGESTION_ENDPOINT, - "http://eastus-0.in.applicationinsights.azure.com/v2.1/track")).isFalse(); - assertThat(isTrustedIngestionRedirect(INGESTION_ENDPOINT, - "https://user@eastus-0.in.applicationinsights.azure.com/v2.1/track")).isFalse(); - assertThat(isTrustedIngestionRedirect(INGESTION_ENDPOINT, - "https://eastus-0.in.applicationinsights.azure.com:444/v2.1/track")).isFalse(); - } - - private static boolean isTrustedLiveMetricsRedirect(String configuredEndpoint, String redirectLink) - throws MalformedURLException { - return RedirectPolicyHelper.isTrustedLiveMetricsRedirect(new URL(configuredEndpoint), new URL(redirectLink)); - } - - private static boolean isTrustedIngestionRedirect(String currentUrl, String redirectLink) - throws MalformedURLException { - return RedirectPolicyHelper.isTrustedIngestionRedirect(new URL(currentUrl), new URL(redirectLink)); + private static boolean isTrustedRedirect(String currentUrl, String redirectLink) throws MalformedURLException { + return RedirectPolicyHelper.isTrustedRedirect(new URL(currentUrl), new URL(redirectLink)); } }