diff --git a/communication/src/main/java/datadog/communication/BackendApiFactory.java b/communication/src/main/java/datadog/communication/BackendApiFactory.java index 2ac0447fc7d..c1b471ebfc8 100644 --- a/communication/src/main/java/datadog/communication/BackendApiFactory.java +++ b/communication/src/main/java/datadog/communication/BackendApiFactory.java @@ -8,6 +8,7 @@ import datadog.trace.util.throwable.FatalAgentMisconfigurationError; import javax.annotation.Nullable; import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,7 +49,13 @@ public BackendApi createDirectIntakeApi(Intake intake) { /** Creates an authenticated API client that sends data directly to a Datadog intake. */ public BackendApi createDirectIntakeApi(Intake intake, boolean responseCompression) { - HttpUrl agentlessUrl = HttpUrl.get(intake.getAgentlessUrl(config)); + return createDirectIntakeApi(intake, responseCompression, true); + } + + /** Creates an authenticated API client that sends data directly to a Datadog intake. */ + public BackendApi createDirectIntakeApi( + Intake intake, boolean responseCompression, boolean followRedirects) { + HttpUrl agentlessUrl = buildDirectIntakeUrl(intake, config); String apiKey = config.getApiKey(); if (apiKey == null || apiKey.isEmpty()) { throw new FatalAgentMisconfigurationError( @@ -60,10 +67,45 @@ public BackendApi createDirectIntakeApi(Intake intake, boolean responseCompressi apiKey, traceId, retryPolicyFactory(), - sharedCommunicationObjects.getIntakeHttpClient(), + directIntakeHttpClient(sharedCommunicationObjects.getIntakeHttpClient(), followRedirects), responseCompression); } + static OkHttpClient directIntakeHttpClient( + final OkHttpClient intakeHttpClient, final boolean followRedirects) { + if (followRedirects) { + return intakeHttpClient; + } + return intakeHttpClient.newBuilder().followRedirects(false).build(); + } + + private static HttpUrl buildDirectIntakeUrl(Intake intake, Config config) { + if (intake != Intake.EVENT_PLATFORM) { + return HttpUrl.get(intake.getAgentlessUrl(config)); + } + return buildEventPlatformIntakeUrl(config.getSite()); + } + + static HttpUrl buildEventPlatformIntakeUrl(String site) { + if (site == null || site.isEmpty()) { + throw new IllegalArgumentException("Invalid Datadog site"); + } + + String expectedHost = Intake.EVENT_PLATFORM.getUrlPrefix() + "." + site; + HttpUrl url = + new HttpUrl.Builder() + .scheme("https") + .host(expectedHost) + .addPathSegment("api") + .addPathSegment(Intake.EVENT_PLATFORM.getVersion()) + .addPathSegment("") + .build(); + if (!url.host().equalsIgnoreCase(expectedHost)) { + throw new IllegalArgumentException("Invalid Datadog site"); + } + return url; + } + /** Creates an API client that uses the specified retry policy with a compatible local proxy. */ public @Nullable BackendApi createEvpProxyApi(Intake intake) { return createEvpProxyApi(intake, true); diff --git a/communication/src/test/java/datadog/communication/BackendApiFactoryTest.java b/communication/src/test/java/datadog/communication/BackendApiFactoryTest.java index 726c34a7f73..44690ef1c3f 100644 --- a/communication/src/test/java/datadog/communication/BackendApiFactoryTest.java +++ b/communication/src/test/java/datadog/communication/BackendApiFactoryTest.java @@ -13,7 +13,9 @@ import datadog.trace.api.Config; import datadog.trace.api.ProtocolVersion; import datadog.trace.api.intake.Intake; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.Locale; import okhttp3.HttpUrl; import okhttp3.MediaType; import okhttp3.OkHttpClient; @@ -22,11 +24,99 @@ import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; class BackendApiFactoryTest { private static final MediaType JSON = MediaType.parse("application/json"); + @ParameterizedTest + @ValueSource(strings = {"datadoghq.com", "custom.example", "DATADOGHQ.EU"}) + void eventPlatformDirectIntakeUsesExactHttpsHost(String site) { + final HttpUrl url = BackendApiFactory.buildEventPlatformIntakeUrl(site); + + assertEquals("https", url.scheme()); + assertEquals("event-platform-intake." + site.toLowerCase(Locale.ROOT), url.host()); + assertEquals(443, url.port()); + assertEquals("/api/v2/", url.encodedPath()); + assertEquals("", url.username()); + assertEquals("", url.password()); + assertNull(url.encodedQuery()); + assertNull(url.encodedFragment()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource( + strings = { + "datadoghq.com@evil.example", + "datadoghq.com:password@evil.example", + "https://datadoghq.com", + "datadoghq.com:443", + "datadoghq.com:8443", + "datadoghq.com/path", + "datadoghq.com?query=value", + "datadoghq.com#fragment", + "dätadoghq.com", + "data doghq.com", + " datadoghq.com", + "datadoghq.com ", + "datadoghq.com\\evil.example" + }) + void eventPlatformDirectIntakeRejectsUnsafeSite(String site) { + assertThrows( + IllegalArgumentException.class, () -> BackendApiFactory.buildEventPlatformIntakeUrl(site)); + } + + @ParameterizedTest + @ValueSource(ints = {301, 302, 307, 308}) + void featureFlagDirectIntakeDoesNotFollowRedirects(final int statusCode) throws Exception { + final MockWebServer intake = new MockWebServer(); + final MockWebServer redirectTarget = new MockWebServer(); + final OkHttpClient sharedClient = new OkHttpClient.Builder().build(); + final OkHttpClient directClient = BackendApiFactory.directIntakeHttpClient(sharedClient, false); + redirectTarget.start(); + intake.enqueue( + new MockResponse() + .setResponseCode(statusCode) + .setHeader("Location", redirectTarget.url("/redirected"))); + intake.start(); + try { + final IntakeApi api = + new IntakeApi( + intake.url("/api/v2/"), + "api-key", + "123", + HttpRetryPolicy.Factory.NEVER_RETRY, + directClient, + false); + + assertThrows( + IOException.class, + () -> + api.post( + "flagevaluation", + RequestBody.create(JSON, "{}".getBytes(StandardCharsets.UTF_8)), + stream -> null, + null, + false)); + + final RecordedRequest request = intake.takeRequest(); + assertEquals("api-key", request.getHeader("DD-API-KEY")); + assertEquals(1, intake.getRequestCount()); + assertEquals(0, redirectTarget.getRequestCount()); + } finally { + directClient.dispatcher().executorService().shutdownNow(); + directClient.connectionPool().evictAll(); + sharedClient.dispatcher().executorService().shutdownNow(); + sharedClient.connectionPool().evictAll(); + intake.shutdown(); + redirectTarget.shutdown(); + } + } + @Test void noBackendApiWhenAgentDoesNotAdvertiseEvpProxy() { final FakeFeaturesDiscovery discovery = new FakeFeaturesDiscovery(null); diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java index 0dbf9c74254..451b903ecc7 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java @@ -90,7 +90,7 @@ private BackendApi createDirectApi() { } try { return backendApiFactory.createDirectIntakeApi( - Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled()); + Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled(), false); } catch (final IllegalArgumentException exception) { LOGGER.debug( "Cannot configure direct Feature Flagging {} delivery", eventType.logName(), exception); diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java index daaf213ebfb..f70a4ff0fa1 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java @@ -168,7 +168,7 @@ void testAgentlessExposureEventWritesDirectlyWithApiKey() throws Exception { new OkHttpClient.Builder().build(), false); when(backendApiFactory.createDirectIntakeApi( - datadog.trace.api.intake.Intake.EVENT_PLATFORM, true)) + eq(datadog.trace.api.intake.Intake.EVENT_PLATFORM), eq(true), eq(false))) .thenReturn(directApi); FeatureFlagBackendApiFactory exposureBackendApiFactory = new FeatureFlagBackendApiFactory(config, backendApiFactory, FeatureFlagEventType.EXPOSURE); @@ -311,7 +311,7 @@ void testAmbiguousExposureBatchIsNotRetriedOrReplayedDirectly() throws Exception when(backendApiFactory.createEvpProxyApi( Intake.EVENT_PLATFORM, true, HttpRetryPolicy.Factory.NEVER_RETRY)) .thenReturn(proxyApi); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, true)) + when(backendApiFactory.createDirectIntakeApi(eq(Intake.EVENT_PLATFORM), eq(true), eq(false))) .thenReturn(directApi); when(proxyApi.post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))) .thenThrow(new SocketTimeoutException("ambiguous timeout")) diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java index 8b117742715..b89cf3ce2b1 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java @@ -32,7 +32,7 @@ void remoteConfigUsesOnlyLocalEvpProxy() { new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); assertSame(proxyApi, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); } @Test @@ -45,7 +45,7 @@ void remoteConfigDisablesDeliveryWhenLocalEvpProxyIsUnavailable() { assertNull(selected); verify(backendApiFactory).createEvpProxyApi(Intake.EVENT_PLATFORM, true); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true, false); } @Test @@ -55,7 +55,7 @@ void agentlessPrefersLocalEvpProxyWithDirectFallback() { when(backendApiFactory.createEvpProxyApi( Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY)) .thenReturn(mock(BackendApi.class)); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false)) + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) .thenReturn(mock(BackendApi.class)); final BackendApi selected = @@ -64,7 +64,7 @@ void agentlessPrefersLocalEvpProxyWithDirectFallback() { assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); verify(backendApiFactory) .createEvpProxyApi(Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); } @Test @@ -72,7 +72,7 @@ void agentlessUsesDirectIntakeWhenLocalEvpProxyIsUnavailable() { final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); final BackendApi directApi = mock(BackendApi.class); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false)) + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) .thenReturn(directApi); final BackendApi selected = @@ -92,7 +92,7 @@ void agentlessUsesLocalEvpProxyWhenApiKeyIsUnavailable() { new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); assertSame(proxyApi, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); } @Test @@ -115,7 +115,7 @@ void agentlessDisablesDeliveryWhenApiKeyIsEmpty() { new FeatureFlagBackendApiFactory(config, backendApiFactory, EXPOSURE).create(); assertNull(selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true, false); } @Test @@ -126,21 +126,21 @@ void agentlessDoesNotValidateDirectUrlWhileLocalRouteIsAvailable() { when(backendApiFactory.createEvpProxyApi( Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY)) .thenReturn(proxyApi); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false)) + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) .thenThrow(new IllegalArgumentException("invalid URL")); final BackendApi selected = new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); } @Test void agentlessDisablesDeliveryWhenDirectUrlIsInvalidAndLocalRouteIsUnavailable() { final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false)) + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) .thenThrow(new IllegalArgumentException("invalid URL")); final BackendApi selected = diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java index ba3d03ec6d5..75a767c5c3f 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java @@ -697,7 +697,8 @@ void agentlessWritesFlagEvaluationsDirectlyWhenLocalProxyIsUnavailable() throws HttpRetryPolicy.Factory.NEVER_RETRY, client, false); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false)) + when(backendApiFactory.createDirectIntakeApi( + eq(Intake.EVENT_PLATFORM), eq(false), eq(false))) .thenReturn(directApi); final FeatureFlagBackendApiFactory featureFlagBackendApiFactory = new FeatureFlagBackendApiFactory(