Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

### Bugs Fixed

- Validate Live Metrics redirect targets before following them ([#50117](https://github.com/Azure/azure-sdk-for-java/pull/50117))

### Other Changes

## 1.5.0 (2026-06-11)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
Comment thread
xiang17 marked this conversation as resolved.
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// 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 static final int DEFAULT_HTTPS_PORT = 443;

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
|| !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)) {
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);
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) {
String normalizedHost = host.toLowerCase(Locale.ROOT);
if (normalizedHost.endsWith(".")) {
return normalizedHost.substring(0, normalizedHost.length() - 1);
}
return normalizedHost;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -120,6 +121,105 @@ 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 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);
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());
}

@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<FilteringConfiguration> 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 {
Expand Down
Loading