diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f69853487f..0b70ad3e496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,114 @@ # Changelog +## Unreleased + +### Features + +- Add `dataCollection`, a fine-grained replacement for `sendDefaultPii`, for controlling data collected automatically by SDK integrations ([#5759](https://github.com/getsentry/sentry-java/pull/5759)) + - `sendDefaultPii` remains supported for backwards compatibility. When `dataCollection` is not configured, the SDK preserves the existing `sendDefaultPii` behavior. + - Configuring any `dataCollection` option makes it the source of truth. `sendDefaultPii` is then ignored, and omitted `dataCollection` options use the defaults below. + - Data explicitly supplied through APIs such as `Sentry.setUser`, scopes, event processors, or `beforeSend` is not affected. + + | Option | Default | Behavior | + | --- | --- | --- | + | `userInfo` | `true` | Allows integrations to populate user identity and IP address information automatically. | + | `cookies` | `{ mode: DENY_LIST, terms: [] }` | Collects cookies while filtering sensitive values. | + | `httpHeaders.request` | `{ mode: DENY_LIST, terms: [] }` | Collects request headers while filtering sensitive values. | + | `httpHeaders.response` | `{ mode: DENY_LIST, terms: [] }` | Collects response headers while filtering sensitive values. | + | `httpBodies` | All supported body types | Collects supported incoming and outgoing request and response bodies. An empty set disables body collection. | + | `urlQueryParams` | `{ mode: DENY_LIST, terms: [] }` | Collects URL query parameters while filtering sensitive values. | + | `graphql.document` | `true` | Collects GraphQL documents. | + | `graphql.variables` | `true` | Collects GraphQL variables. | + | `databaseQueryData` | `true` | Allows collection of associated query data, such as bound parameters, write payloads, and results, where supported. Sanitized query statements and structural database metadata remain available. | + + Cookies, HTTP headers, and URL query parameters support three modes: + + - `OFF`: Do not collect the category. + - `DENY_LIST`: Collect values except those matching the built-in sensitive deny-list or additional configured terms. + - `ALLOW_LIST`: Only send plaintext values for matching terms. The built-in sensitive deny-list still applies. + + Matching is case-insensitive and partial. The built-in sensitive deny-list contains `auth`, `token`, `secret`, `password`, `passwd`, `pwd`, `key`, `jwt`, `bearer`, `sso`, `saml`, `csrf`, `xsrf`, `credentials`, `session`, `sid`, and `identity`. Filtered values are replaced with `"[Filtered]"`. Custom deny-list terms extend rather than replace this list. + + Configure all HTTP body types, a custom cookie deny-list, a request-header allow-list, and disable URL query parameter collection in an options callback: + + ```java + Sentry.init( + options -> { + options + .getDataCollection() + .setHttpBodies( + EnumSet.of( + HttpBodyType.INCOMING_REQUEST, + HttpBodyType.OUTGOING_REQUEST, + HttpBodyType.INCOMING_RESPONSE, + HttpBodyType.OUTGOING_RESPONSE)); + options + .getDataCollection() + .setCookies( + KeyValueCollectionBehavior.denyList( + "forwarded", "-ip", "remote-", "via", "-user")); + options + .getDataCollection() + .getHttpHeaders() + .setRequest( + KeyValueCollectionBehavior.allowList("content-type", "x-request-id")); + options + .getDataCollection() + .setUrlQueryParams(KeyValueCollectionBehavior.off()); + }); + ``` + + Configure the same options in `sentry.properties`: + + ```properties + data-collection.http-bodies=incoming_request,outgoing_request,incoming_response,outgoing_response + data-collection.cookies.mode=deny_list + data-collection.cookies.terms=forwarded,-ip,remote-,via,-user + data-collection.http-headers.request.mode=allow_list + data-collection.http-headers.request.terms=content-type,x-request-id + data-collection.url-query-params.mode=off + ``` + + Configure them with Spring Boot properties: + + ```properties + sentry.data-collection.http-bodies=incoming-request,outgoing-request,incoming-response,outgoing-response + sentry.data-collection.cookies.mode=deny-list + sentry.data-collection.cookies.terms=forwarded,-ip,remote-,via,-user + sentry.data-collection.http-headers.request.mode=allow-list + sentry.data-collection.http-headers.request.terms=content-type,x-request-id + sentry.data-collection.url-query-params.mode=off + ``` + + Configure them in `AndroidManifest.xml`: + + ```xml + + + + + + + ``` + + See the [Data Collection documentation](https://docs.sentry.io/platforms/java/configuration/options/#dataCollection) for all configuration keys, supported integrations, and migration guidance. + +### Fixes + +- Support `ws` and `wss` URL parsing for WebSocket instrumentation ([#6064](https://github.com/getsentry/sentry-java/pull/6064)) + ## 8.55.0 ### Features diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 8d56c365144..acf2742a32e 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -283,7 +283,6 @@ public final class io/sentry/android/core/DeviceInfoUtil { public fun getSplitApksInfo ()Lio/sentry/android/core/ContextUtils$SplitApksInfo; public fun getTotalMemory ()Ljava/lang/Long; public static fun isCharging (Landroid/content/Intent;Lio/sentry/SentryOptions;)Ljava/lang/Boolean; - public static fun resetInstance ()V } public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : io/sentry/Integration, java/io/Closeable { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 31fe2442bf4..13f255bd6ec 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -681,7 +681,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { if (user.getId() == null) { user.setId(getDeviceId()); } - if (user.getIpAddress() == null && options.isSendDefaultPii()) { + if (user.getIpAddress() == null && options.getDataCollectionResolver().isUserInfo()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 83f892573e4..84e953ee69e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -178,7 +178,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { if (user.getId() == null) { user.setId(Installation.id(context)); } - if (user.getIpAddress() == null && options.isSendDefaultPii()) { + if (user.getIpAddress() == null && options.getDataCollectionResolver().isUserInfo()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index 63b88c0e440..d96dc5cb387 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -15,7 +15,6 @@ import android.os.SystemClock; import android.util.DisplayMetrics; import io.sentry.DateUtils; -import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.internal.util.CpuInfoUtils; @@ -23,7 +22,6 @@ import io.sentry.android.core.internal.util.RootChecker; import io.sentry.protocol.Device; import io.sentry.protocol.OperatingSystem; -import io.sentry.util.AutoClosableReentrantLock; import java.io.File; import java.util.Calendar; import java.util.Collections; @@ -34,17 +32,10 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; @ApiStatus.Internal public final class DeviceInfoUtil { - @SuppressLint("StaticFieldLeak") - private static volatile DeviceInfoUtil instance; - - private static final @NotNull AutoClosableReentrantLock staticLock = - new AutoClosableReentrantLock(); - private final @NotNull Context context; private final @NotNull SentryAndroidOptions options; private final @NotNull BuildInfoProvider buildInfoProvider; @@ -80,19 +71,7 @@ public DeviceInfoUtil( @NotNull public static DeviceInfoUtil getInstance( final @NotNull Context context, final @NotNull SentryAndroidOptions options) { - if (instance == null) { - try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { - if (instance == null) { - instance = new DeviceInfoUtil(ContextUtils.getApplicationContext(context), options); - } - } - } - return instance; - } - - @TestOnly - public static void resetInstance() { - instance = null; + return options.getOrCreateDeviceInfoUtil(context); } // we can get some inspiration here diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 1ca91bbada3..e3948165ba1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -3,8 +3,11 @@ import android.content.Context; import android.content.pm.ApplicationInfo; import android.os.Bundle; +import io.sentry.DataCollection; +import io.sentry.HttpBodyType; import io.sentry.ILogger; import io.sentry.InitPriority; +import io.sentry.KeyValueCollectionBehavior; import io.sentry.ProfileLifecycle; import io.sentry.ScreenshotStrategyType; import io.sentry.SentryFeedbackOptions; @@ -16,9 +19,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.EnumSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -106,6 +111,22 @@ final class ManifestMetadataReader { static final String SEND_DEFAULT_PII = "io.sentry.send-default-pii"; + static final String DATA_COLLECTION_USER_INFO = "io.sentry.data-collection.user-info"; + static final String DATA_COLLECTION_HTTP_BODIES = "io.sentry.data-collection.http-bodies"; + static final String DATA_COLLECTION_COOKIES = "io.sentry.data-collection.cookies"; + static final String DATA_COLLECTION_HTTP_REQUEST_HEADERS = + "io.sentry.data-collection.http-headers.request"; + static final String DATA_COLLECTION_HTTP_RESPONSE_HEADERS = + "io.sentry.data-collection.http-headers.response"; + static final String DATA_COLLECTION_URL_QUERY_PARAMS = + "io.sentry.data-collection.url-query-params"; + static final String DATA_COLLECTION_GRAPHQL_DOCUMENT = + "io.sentry.data-collection.graphql.document"; + static final String DATA_COLLECTION_GRAPHQL_VARIABLES = + "io.sentry.data-collection.graphql.variables"; + static final String DATA_COLLECTION_DATABASE_QUERY_DATA = + "io.sentry.data-collection.database-query-data"; + static final String PERFORM_FRAMES_TRACKING = "io.sentry.traces.frames-tracking"; static final String SENTRY_GRADLE_PLUGIN_INTEGRATIONS = "io.sentry.gradle-plugin-integrations"; @@ -779,6 +800,12 @@ static void applyMetadata( options.setEnableAnrFingerprinting( readBool( metadata, logger, ENABLE_ANR_FINGERPRINTING, options.isEnableAnrFingerprinting())); + + final @Nullable DataCollection dataCollection = + readDataCollection(metadata, logger, options.getDataCollection()); + if (dataCollection != null) { + mergeDataCollection(options.getDataCollection(), dataCollection); + } } options .getLogger() @@ -791,6 +818,152 @@ static void applyMetadata( } } + private static @Nullable DataCollection readDataCollection( + final @NotNull Object metadata, + final @NotNull ILogger logger, + final @NotNull DataCollection currentDataCollection) { + final @NotNull DataCollection dataCollection = new DataCollection(false); + + if (containsKey(metadata, DATA_COLLECTION_USER_INFO)) { + dataCollection.setUserInfo(readBool(metadata, logger, DATA_COLLECTION_USER_INFO, false)); + } + + if (containsKey(metadata, DATA_COLLECTION_HTTP_BODIES)) { + dataCollection.setHttpBodies(readHttpBodyTypes(metadata, logger)); + } + + final @Nullable KeyValueCollectionBehavior cookies = + readKeyValueCollectionBehavior( + metadata, logger, DATA_COLLECTION_COOKIES, currentDataCollection.getCookies()); + if (cookies != null) { + dataCollection.setCookies(cookies); + } + + final @Nullable KeyValueCollectionBehavior requestHeaders = + readKeyValueCollectionBehavior( + metadata, + logger, + DATA_COLLECTION_HTTP_REQUEST_HEADERS, + currentDataCollection.getHttpHeaders().getRequest()); + if (requestHeaders != null) { + dataCollection.getHttpHeaders().setRequest(requestHeaders); + } + + final @Nullable KeyValueCollectionBehavior responseHeaders = + readKeyValueCollectionBehavior( + metadata, + logger, + DATA_COLLECTION_HTTP_RESPONSE_HEADERS, + currentDataCollection.getHttpHeaders().getResponse()); + if (responseHeaders != null) { + dataCollection.getHttpHeaders().setResponse(responseHeaders); + } + + final @Nullable KeyValueCollectionBehavior urlQueryParams = + readKeyValueCollectionBehavior( + metadata, + logger, + DATA_COLLECTION_URL_QUERY_PARAMS, + currentDataCollection.getUrlQueryParams()); + if (urlQueryParams != null) { + dataCollection.setUrlQueryParams(urlQueryParams); + } + + if (containsKey(metadata, DATA_COLLECTION_GRAPHQL_DOCUMENT)) { + dataCollection + .getGraphql() + .setDocument(readBool(metadata, logger, DATA_COLLECTION_GRAPHQL_DOCUMENT, false)); + } + + if (containsKey(metadata, DATA_COLLECTION_GRAPHQL_VARIABLES)) { + dataCollection + .getGraphql() + .setVariables(readBool(metadata, logger, DATA_COLLECTION_GRAPHQL_VARIABLES, false)); + } + + if (containsKey(metadata, DATA_COLLECTION_DATABASE_QUERY_DATA)) { + dataCollection.setDatabaseQueryData( + readBool(metadata, logger, DATA_COLLECTION_DATABASE_QUERY_DATA, false)); + } + + return dataCollection.isExplicitlyConfigured() ? dataCollection : null; + } + + private static void mergeDataCollection( + final @NotNull DataCollection target, final @NotNull DataCollection source) { + if (source.getUserInfo() != null) { + target.setUserInfo(source.getUserInfo()); + } + if (source.getHttpBodies() != null) { + target.setHttpBodies(source.getHttpBodies()); + } + if (source.getCookies() != null) { + target.setCookies(source.getCookies()); + } + if (source.getHttpHeaders().getRequest() != null) { + target.getHttpHeaders().setRequest(source.getHttpHeaders().getRequest()); + } + if (source.getHttpHeaders().getResponse() != null) { + target.getHttpHeaders().setResponse(source.getHttpHeaders().getResponse()); + } + if (source.getUrlQueryParams() != null) { + target.setUrlQueryParams(source.getUrlQueryParams()); + } + if (source.getGraphql().getDocument() != null) { + target.getGraphql().setDocument(source.getGraphql().getDocument()); + } + if (source.getGraphql().getVariables() != null) { + target.getGraphql().setVariables(source.getGraphql().getVariables()); + } + if (source.getDatabaseQueryData() != null) { + target.setDatabaseQueryData(source.getDatabaseQueryData()); + } + } + + private static @NotNull Set readHttpBodyTypes( + final @NotNull Object metadata, final @NotNull ILogger logger) { + final @Nullable List bodyTypes = + readList(metadata, logger, DATA_COLLECTION_HTTP_BODIES); + if (bodyTypes == null || (bodyTypes.size() == 1 && bodyTypes.get(0).isEmpty())) { + return Collections.emptySet(); + } + + final @NotNull Set result = EnumSet.noneOf(HttpBodyType.class); + for (final String bodyType : bodyTypes) { + result.add(HttpBodyType.valueOf(bodyType.toUpperCase(Locale.ROOT))); + } + return result; + } + + private static @Nullable KeyValueCollectionBehavior readKeyValueCollectionBehavior( + final @NotNull Object metadata, + final @NotNull ILogger logger, + final @NotNull String key, + final @Nullable KeyValueCollectionBehavior currentBehavior) { + final @NotNull String modeKey = key + ".mode"; + final @NotNull String termsKey = key + ".terms"; + if (!containsKey(metadata, modeKey) && !containsKey(metadata, termsKey)) { + return null; + } + + final @NotNull KeyValueCollectionBehavior behavior = new KeyValueCollectionBehavior(); + if (currentBehavior != null) { + behavior.setMode(currentBehavior.getMode()); + behavior.setTerms(currentBehavior.getTerms()); + } + if (containsKey(metadata, modeKey)) { + final @Nullable String mode = readString(metadata, logger, modeKey, null); + if (mode != null) { + behavior.setMode(KeyValueCollectionBehavior.Mode.valueOf(mode.toUpperCase(Locale.ROOT))); + } + } + if (containsKey(metadata, termsKey)) { + final @Nullable List terms = readList(metadata, logger, termsKey); + behavior.setTerms(terms == null ? Collections.emptyList() : terms); + } + return behavior; + } + private static boolean readBool( final @NotNull Object metadata, final @NotNull ILogger logger, diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 66a3700d388..ac716578c40 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -3,8 +3,10 @@ import android.app.Activity; import android.app.ActivityManager; import android.app.ApplicationExitInfo; +import android.content.Context; import io.sentry.Hint; import io.sentry.IScope; +import io.sentry.ISentryLifecycleToken; import io.sentry.ISpan; import io.sentry.Sentry; import io.sentry.SentryEvent; @@ -141,6 +143,8 @@ public final class SentryAndroidOptions extends SentryOptions { /** Enables or disables collecting of external storage context. */ private boolean collectExternalStorageContext = false; + private volatile @Nullable DeviceInfoUtil deviceInfoUtil; + /** * Controls how many seconds to wait for sending events in case there were Startup Crashes in the * previous run. Sentry SDKs normally send events from a background queue, but in the case of @@ -200,6 +204,18 @@ public final class SentryAndroidOptions extends SentryOptions { /** Enable or disable intent extras reporting for system event breadcrumbs. Default is false. */ private boolean enableSystemEventBreadcrumbsExtras = false; + @NotNull + DeviceInfoUtil getOrCreateDeviceInfoUtil(final @NotNull Context context) { + if (deviceInfoUtil == null) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (deviceInfoUtil == null) { + deviceInfoUtil = new DeviceInfoUtil(ContextUtils.getApplicationContext(context), this); + } + } + } + return deviceInfoUtil; + } + public interface BeforeCaptureCallback { /** diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index f484f994dbb..feda9a684c7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -201,8 +201,6 @@ class ApplicationExitInfoEventProcessorTest { @BeforeTest fun `set up`() { - DeviceInfoUtil.resetInstance() - ContextUtils.resetInstance() fixture.context = ApplicationProvider.getApplicationContext() } @@ -233,6 +231,26 @@ class ApplicationExitInfoEventProcessorTest { assertEquals(SentryBaseEvent.DEFAULT_PLATFORM, processed.platform) } + @Test + fun `when user info is disabled, sets device id`() { + fixture.options.dataCollection.setUserInfo(false) + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + + val processed = processEvent(hint) + + assertNotNull(processed.contexts.device!!.id) + } + + @Test + fun `when user info is enabled, sets device id`() { + fixture.options.dataCollection.setUserInfo(true) + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + + val processed = processEvent(hint, isSendDefaultPii = false) + + assertNotNull(processed.contexts.device!!.id) + } + @Test fun `when backfillable event is not enrichable, sets OS`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint(shouldEnrich = false)) @@ -356,6 +374,28 @@ class ApplicationExitInfoEventProcessorTest { assertNull(processed.user!!.ipAddress) } + @Test + fun `when user info is disabled, does not backfill automatic user data`() { + fixture.options.dataCollection.setUserInfo(false) + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processed = processEvent(hint, isSendDefaultPii = true, populateScopeCache = true) + + assertEquals("bot", processed.user!!.username) + assertEquals("bot@me.com", processed.user!!.id) + assertNull(processed.user!!.ipAddress) + } + + @Test + fun `when user info is enabled, backfills automatic user data`() { + fixture.options.dataCollection.setUserInfo(true) + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processed = processEvent(hint, isSendDefaultPii = false, populateScopeCache = true) + + assertEquals("bot", processed.user!!.username) + assertEquals("bot@me.com", processed.user!!.id) + assertEquals("{{auto}}", processed.user!!.ipAddress) + } + @Test fun `when backfillable event is enrichable, backfills serialized options data`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -640,6 +680,19 @@ class ApplicationExitInfoEventProcessorTest { assertEquals(Installation.deviceId, processed!!.user!!.id) } + @Test + fun `when user info is disabled, sets installation id for missing user id`() { + fixture.options.dataCollection.setUserInfo(false) + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val original = SentryEvent() + val processor = fixture.getSut(tmpDir) + fixture.persistOptions(USER_FILENAME, User()) + + val processed = processor.process(original, hint) + + assertEquals(Installation.deviceId, processed!!.user!!.id) + } + @Test fun `when event has some fields set, does not override them`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt index 091a75e1295..460e0bbec1d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt @@ -87,7 +87,6 @@ class DefaultAndroidEventProcessorTest { fun `set up`() { context = ApplicationProvider.getApplicationContext() AppState.getInstance().resetInstance() - DeviceInfoUtil.resetInstance() CpuInfoUtils.getInstance().clear() } @@ -285,6 +284,34 @@ class DefaultAndroidEventProcessorTest { assertNotNull(event.user) { assertEquals("{{auto}}", it.ipAddress) } } + @Test + fun `when user info is disabled, sets installation id but not automatic ip`() { + fixture.options.dataCollection.setUserInfo(false) + val sut = fixture.getSut(context, isSendDefaultPii = true) + val event = SentryEvent().apply { user = User() } + + sut.process(event, Hint()) + + assertNotNull(event.user) { + assertNotNull(it.id) + assertNull(it.ipAddress) + } + } + + @Test + fun `when user info is enabled, sets automatic user data`() { + fixture.options.dataCollection.setUserInfo(true) + val sut = fixture.getSut(context, isSendDefaultPii = false) + val event = SentryEvent().apply { user = User() } + + sut.process(event, Hint()) + + assertNotNull(event.user) { + assertNotNull(it.id) + assertEquals("{{auto}}", it.ipAddress) + } + } + @Test fun `when event has ip address set, keeps original ip address`() { val sut = fixture.getSut(context) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt index faf993e1610..fffdf6257ea 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt @@ -15,7 +15,9 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNotSame import kotlin.test.assertNull +import kotlin.test.assertSame import org.junit.runner.RunWith import org.robolectric.annotation.Config @@ -32,7 +34,45 @@ class DeviceInfoUtilTest { .putExtra(BatteryManager.EXTRA_LEVEL, 75) .putExtra(BatteryManager.EXTRA_PLUGGED, 0) ) - DeviceInfoUtil.resetInstance() + } + + @Test + fun `same options reuse device info util`() { + val options = SentryAndroidOptions() + + val first = DeviceInfoUtil.getInstance(context, options) + val second = DeviceInfoUtil.getInstance(context, options) + + assertSame(first, second) + } + + @Test + fun `different options use isolated device info utils`() { + val enabledOptions = + SentryAndroidOptions().apply { + dataCollection.setUserInfo(true) + isCollectAdditionalContext = true + isEnableRootCheck = true + } + val disabledOptions = + SentryAndroidOptions().apply { + dataCollection.setUserInfo(false) + isCollectAdditionalContext = false + isEnableRootCheck = false + } + + val enabled = DeviceInfoUtil.getInstance(context, enabledOptions) + val disabled = DeviceInfoUtil.getInstance(context, disabledOptions) + val enabledDevice = enabled.collectDeviceInformation(true, false) + val disabledDevice = disabled.collectDeviceInformation(true, false) + + assertNotSame(enabled, disabled) + assertNotNull(enabledDevice.id) + assertNotNull(enabledDevice.storageSize) + assertNotNull(enabled.operatingSystem.isRooted) + assertNotNull(disabledDevice.id) + assertNull(disabledDevice.storageSize) + assertNull(disabled.operatingSystem.isRooted) } @Test @@ -53,6 +93,24 @@ class DeviceInfoUtilTest { assertNotNull(deviceInfo.memorySize) } + @Test + fun `sets device id when user info is disabled`() { + val options = SentryAndroidOptions().apply { dataCollection.setUserInfo(false) } + val deviceInfo = + DeviceInfoUtil.getInstance(context, options).collectDeviceInformation(false, false) + + assertNotNull(deviceInfo.id) + } + + @Test + fun `sets device id when user info is enabled`() { + val options = SentryAndroidOptions().apply { dataCollection.setUserInfo(true) } + val deviceInfo = + DeviceInfoUtil.getInstance(context, options).collectDeviceInformation(false, false) + + assertNotNull(deviceInfo.id) + } + @Test fun `sets default timezone`() { val deviceInfoUtil = DeviceInfoUtil.getInstance(context, SentryAndroidOptions()) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt index 870b71855fb..6cb62604ab1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt @@ -258,7 +258,6 @@ class InternalSentrySdkTest { fun `set up`() { Sentry.close() context = ApplicationProvider.getApplicationContext() - DeviceInfoUtil.resetInstance() } @Test @@ -354,6 +353,28 @@ class InternalSentrySdkTest { assertTrue((serializedScope["user"] as Map<*, *>).containsKey("id")) } + @Test + fun `serializeScope provides fallback user id when user info is disabled`() { + val options = SentryAndroidOptions().apply { dataCollection.setUserInfo(false) } + val scope = Scope(options) + scope.user = null + + val serializedScope = InternalSentrySdk.serializeScope(context, options, scope) + + assertTrue((serializedScope["user"] as Map<*, *>).containsKey("id")) + } + + @Test + fun `serializeScope provides fallback user id when user info is enabled`() { + val options = SentryAndroidOptions().apply { dataCollection.setUserInfo(true) } + val scope = Scope(options) + scope.user = null + + val serializedScope = InternalSentrySdk.serializeScope(context, options, scope) + + assertTrue((serializedScope["user"] as Map<*, *>).containsKey("id")) + } + @Test fun `serializeScope does not override user-id`() { val options = SentryAndroidOptions() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index c387cc8794b..8b276e704ab 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -6,7 +6,9 @@ import androidx.core.os.bundleOf import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import io.sentry.FilterString +import io.sentry.HttpBodyType import io.sentry.ILogger +import io.sentry.KeyValueCollectionBehavior import io.sentry.ProfileLifecycle import io.sentry.SentryLevel import io.sentry.SentryReplayOptions @@ -1458,6 +1460,139 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isSendDefaultPii) } + @Test + fun `applyMetadata preserves legacy data collection when metadata is absent`() { + val context = fixture.getContext() + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollectionResolver.isDataCollectionConfigured()).isFalse() + } + + @Test + fun `applyMetadata reads data collection options`() { + val bundle = + bundleOf( + ManifestMetadataReader.DATA_COLLECTION_USER_INFO to false, + ManifestMetadataReader.DATA_COLLECTION_HTTP_BODIES to "incoming_request,outgoing_response", + ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".mode" to "deny_list", + ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".terms" to "authorization,session", + ManifestMetadataReader.DATA_COLLECTION_HTTP_REQUEST_HEADERS + ".mode" to "allow_list", + ManifestMetadataReader.DATA_COLLECTION_HTTP_REQUEST_HEADERS + ".terms" to + "x-request-id,content-type", + ManifestMetadataReader.DATA_COLLECTION_HTTP_RESPONSE_HEADERS + ".mode" to "off", + ManifestMetadataReader.DATA_COLLECTION_URL_QUERY_PARAMS + ".terms" to "search", + ManifestMetadataReader.DATA_COLLECTION_GRAPHQL_DOCUMENT to false, + ManifestMetadataReader.DATA_COLLECTION_GRAPHQL_VARIABLES to true, + ManifestMetadataReader.DATA_COLLECTION_DATABASE_QUERY_DATA to false, + ) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + val dataCollection = fixture.options.dataCollection + assertThat(dataCollection.userInfo).isFalse() + assertThat(dataCollection.httpBodies) + .containsExactly(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + assertThat(dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.denyList("authorization", "session")) + assertThat(dataCollection.httpHeaders.request) + .isEqualTo(KeyValueCollectionBehavior.allowList("x-request-id", "content-type")) + assertThat(dataCollection.httpHeaders.response).isEqualTo(KeyValueCollectionBehavior.off()) + assertThat(dataCollection.urlQueryParams) + .isEqualTo(KeyValueCollectionBehavior.denyList("search")) + assertThat(dataCollection.graphql.document).isFalse() + assertThat(dataCollection.graphql.variables).isTrue() + assertThat(dataCollection.databaseQueryData).isFalse() + } + + @Test + fun `applyMetadata only overrides explicitly configured data collection options`() { + val dataCollection = + fixture.options.dataCollection.apply { + setUserInfo(true) + setHttpBodies(setOf(HttpBodyType.OUTGOING_REQUEST)) + cookies = KeyValueCollectionBehavior.allowList("existing-cookie") + httpHeaders.request = KeyValueCollectionBehavior.denyList("existing-request-header") + httpHeaders.response = KeyValueCollectionBehavior.allowList("existing-response-header") + urlQueryParams = KeyValueCollectionBehavior.off() + graphql.setDocument(true) + graphql.setVariables(false) + setDatabaseQueryData(true) + } + val bundle = + bundleOf( + ManifestMetadataReader.DATA_COLLECTION_USER_INFO to false, + ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".terms" to "manifest-cookie", + ManifestMetadataReader.DATA_COLLECTION_HTTP_REQUEST_HEADERS + ".mode" to "allow_list", + ) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollection).isSameInstanceAs(dataCollection) + assertThat(dataCollection.userInfo).isFalse() + assertThat(dataCollection.httpBodies).containsExactly(HttpBodyType.OUTGOING_REQUEST) + assertThat(dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.allowList("manifest-cookie")) + assertThat(dataCollection.httpHeaders.request) + .isEqualTo(KeyValueCollectionBehavior.allowList("existing-request-header")) + assertThat(dataCollection.httpHeaders.response) + .isEqualTo(KeyValueCollectionBehavior.allowList("existing-response-header")) + assertThat(dataCollection.urlQueryParams).isEqualTo(KeyValueCollectionBehavior.off()) + assertThat(dataCollection.graphql.document).isTrue() + assertThat(dataCollection.graphql.variables).isFalse() + assertThat(dataCollection.databaseQueryData).isTrue() + } + + @Test + fun `applyMetadata reads empty HTTP bodies as disabled`() { + val bundle = bundleOf(ManifestMetadataReader.DATA_COLLECTION_HTTP_BODIES to "") + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollection.httpBodies).isEmpty() + } + + @Test + fun `applyMetadata data collection takes precedence over send default pii`() { + val bundle = + bundleOf( + ManifestMetadataReader.SEND_DEFAULT_PII to false, + ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".mode" to "off", + ) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.isSendDefaultPii).isFalse() + assertThat(fixture.options.dataCollectionResolver.isDataCollectionConfigured()).isTrue() + assertThat(fixture.options.dataCollectionResolver.isUserInfo).isTrue() + assertThat(fixture.options.dataCollectionResolver.cookies) + .isEqualTo(KeyValueCollectionBehavior.off()) + } + + @Test + fun `applyMetadata ignores invalid data collection body type`() { + val bundle = bundleOf(ManifestMetadataReader.DATA_COLLECTION_HTTP_BODIES to "invalid") + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollectionResolver.isDataCollectionConfigured()).isFalse() + } + + @Test + fun `applyMetadata ignores invalid data collection mode`() { + val bundle = bundleOf(ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".mode" to "invalid") + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollectionResolver.isDataCollectionConfigured()).isFalse() + } + @Test fun `applyMetadata reads frames tracking flag and keeps default value if not found`() { // Arrange diff --git a/sentry-apollo-3/api/sentry-apollo-3.api b/sentry-apollo-3/api/sentry-apollo-3.api index e106585156f..9df63356733 100644 --- a/sentry-apollo-3/api/sentry-apollo-3.api +++ b/sentry-apollo-3/api/sentry-apollo-3.api @@ -35,6 +35,8 @@ public final class io/sentry/apollo3/SentryApollo3HttpInterceptor$Companion { public final class io/sentry/apollo3/SentryApollo3Interceptor : com/apollographql/apollo3/interceptor/ApolloInterceptor { public fun ()V + public fun (Lio/sentry/IScopes;)V + public synthetic fun (Lio/sentry/IScopes;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun intercept (Lcom/apollographql/apollo3/api/ApolloRequest;Lcom/apollographql/apollo3/interceptor/ApolloInterceptorChain;)Lkotlinx/coroutines/flow/Flow; } diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index 8337eeb7b15..94ba52cb592 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -27,6 +27,8 @@ import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Mechanism import io.sentry.protocol.Request import io.sentry.protocol.Response +import io.sentry.util.CookieUtils +import io.sentry.util.GraphqlUtils import io.sentry.util.HttpUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.Platform @@ -159,7 +161,7 @@ constructor( operationType: String?, operationId: String?, ): ISpan { - val urlDetails = UrlUtils.parse(request.url) + val urlDetails = UrlUtils.parse(request.url, scopes.options.dataCollectionResolver) val method = request.method.name val operation = if (operationType != null) "http.graphql.$operationType" else "http.graphql" @@ -174,7 +176,9 @@ constructor( operationId?.let { setData("operationId", it) } - variables?.let { setData("variables", it) } + if (scopes.options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways) { + variables?.let { setData("variables", it) } + } setData(HTTP_METHOD_KEY, method.uppercase()) } } @@ -229,7 +233,13 @@ constructor( span.finish() } - val breadcrumb = Breadcrumb.http(request.url, request.method.name, statusCode) + val breadcrumb = + Breadcrumb.http( + request.url, + request.method.name, + statusCode, + scopes.options.dataCollectionResolver, + ) request.body?.contentLength.ifHasValidLength { contentLength -> breadcrumb.setData("request_body_size", contentLength) @@ -261,6 +271,36 @@ constructor( private fun getHeader(key: String, headers: List): String? = headers.firstOrNull { it.name.equals(key, true) }?.value + private fun getRequestHeaders(headers: List): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val requestHeaders = mutableMapOf() + for (header in headers) { + requestHeaders[header.name] = header.value + } + return HttpUtils.filterHeaders( + requestHeaders, + scopes.options.dataCollectionResolver.httpRequestHeaders, + ) + .toMutableMap() + } + return getHeaders(headers) + } + + private fun getResponseHeaders(headers: List): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val responseHeaders = mutableMapOf() + for (header in headers) { + responseHeaders[header.name] = header.value + } + return HttpUtils.filterHeaders( + responseHeaders, + scopes.options.dataCollectionResolver.httpResponseHeaders, + ) + .toMutableMap() + } + return getHeaders(headers) + } + private fun getHeaders(headers: List): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII if (!scopes.options.isSendDefaultPii) { @@ -318,7 +358,7 @@ constructor( // url will be: https://api.github.com/users/getsentry/repos/ // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/ // but that's not possible - val urlDetails = UrlUtils.parse(request.url) + val urlDetails = UrlUtils.parse(request.url, scopes.options.dataCollectionResolver) // return if its not a target match if (!PropagationTargetsUtils.contain(failedRequestTargets, urlDetails.urlOrFallback)) { @@ -352,46 +392,43 @@ constructor( val sentryRequest = Request().apply { urlDetails.applyToRequest(this) - // Cookie is only sent if isSendDefaultPii is enabled - cookies = - if (scopes.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null + cookies = CookieUtils.filterCookies(getHeader("Cookie", request.headers), scopes.options) method = request.method.name - headers = getHeaders(request.headers) + headers = getRequestHeaders(request.headers) apiTarget = "graphql" request.body?.let { bodySize = it.contentLength - val buffer = Buffer() - - try { - it.writeTo(buffer) - data = buffer.readUtf8() - } catch (e: Throwable) { - scopes.options.logger.log(SentryLevel.ERROR, "Error reading the request body.", e) - // continue because the response body alone can already give some insights - } finally { - buffer.close() + if (scopes.options.dataCollectionResolver.isOutgoingRequestBody) { + val buffer = Buffer() + + try { + it.writeTo(buffer) + data = GraphqlUtils.filterRequestBody(buffer.readUtf8(), scopes.options) + } catch (e: Throwable) { + scopes.options.logger.log(SentryLevel.ERROR, "Error reading the request body.", e) + // continue because the response body alone can already give some insights + } finally { + buffer.close() + } } } } val sentryResponse = Response().apply { - // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII cookies = - if (scopes.options.isSendDefaultPii) { - getHeader("Set-Cookie", response.headers) - } else { - null - } - headers = getHeaders(response.headers) + CookieUtils.filterSetCookie(getHeader("Set-Cookie", response.headers), scopes.options) + headers = getResponseHeaders(response.headers) statusCode = response.statusCode response.body?.buffer?.size?.ifHasValidLength { contentLength -> bodySize = contentLength } - data = body + if (scopes.options.dataCollectionResolver.isIncomingResponseBody) { + data = body + } } fingerprints.add(response.statusCode.toString()) diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3Interceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3Interceptor.kt index ea0fa1fa18e..b58a2551566 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3Interceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3Interceptor.kt @@ -10,12 +10,16 @@ import com.apollographql.apollo3.api.Subscription import com.apollographql.apollo3.api.variables import com.apollographql.apollo3.interceptor.ApolloInterceptor import com.apollographql.apollo3.interceptor.ApolloInterceptorChain +import io.sentry.IScopes +import io.sentry.ScopesAdapter import io.sentry.apollo3.SentryApollo3HttpInterceptor.Companion.SENTRY_APOLLO_3_OPERATION_TYPE import io.sentry.apollo3.SentryApollo3HttpInterceptor.Companion.SENTRY_APOLLO_3_VARIABLES import io.sentry.vendor.Base64 import kotlinx.coroutines.flow.Flow -class SentryApollo3Interceptor : ApolloInterceptor { +class SentryApollo3Interceptor +@JvmOverloads +constructor(private val scopes: IScopes = ScopesAdapter.getInstance()) : ApolloInterceptor { override fun intercept( request: ApolloRequest, chain: ApolloInterceptorChain, @@ -28,14 +32,16 @@ class SentryApollo3Interceptor : ApolloInterceptor { Base64.encodeToString(operationType(request).toByteArray(), Base64.NO_WRAP), ) - request.scalarAdapters?.let { - builder.addHttpHeader( - SENTRY_APOLLO_3_VARIABLES, - Base64.encodeToString( - request.operation.variables(it).valueMap.toString().toByteArray(), - Base64.NO_WRAP, - ), - ) + if (scopes.options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways) { + request.scalarAdapters?.let { + builder.addHttpHeader( + SENTRY_APOLLO_3_VARIABLES, + Base64.encodeToString( + request.operation.variables(it).valueMap.toString().toByteArray(), + Base64.NO_WRAP, + ), + ) + } } return chain.proceed(builder.build()) } diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApolloBuilderExtensions.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApolloBuilderExtensions.kt index b5498a31316..076cfea521d 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApolloBuilderExtensions.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApolloBuilderExtensions.kt @@ -13,7 +13,7 @@ fun ApolloClient.Builder.sentryTracing( failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS), beforeSpan: SentryApollo3HttpInterceptor.BeforeSpanCallback? = null, ): ApolloClient.Builder { - addInterceptor(SentryApollo3Interceptor()) + addInterceptor(SentryApollo3Interceptor(scopes)) addHttpInterceptor( SentryApollo3HttpInterceptor( scopes = scopes, diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt index 78be36f83b0..315f15a97b3 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt @@ -5,7 +5,9 @@ import com.apollographql.apollo3.api.http.HttpRequest import com.apollographql.apollo3.api.http.HttpResponse import com.apollographql.apollo3.exception.ApolloException import io.sentry.Hint +import io.sentry.HttpBodyType import io.sentry.IScopes +import io.sentry.KeyValueCollectionBehavior import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS @@ -71,7 +73,9 @@ class SentryApollo3InterceptorClientErrors { httpStatusCode: Int = 200, responseBody: String = responseBodyOk, sendDefaultPii: Boolean = false, + includeCookies: Boolean = sendDefaultPii, socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + configureOptions: SentryOptions.() -> Unit = {}, ): ApolloClient { SentryIntegrationPackageStorage.getInstance().clearStorage() @@ -83,6 +87,7 @@ class SentryApollo3InterceptorClientErrors { dsn = "https://key@sentry.io/proj" sdkVersion = SdkVersion("test", "1.2.3") isSendDefaultPii = sendDefaultPii + configureOptions() } ) } @@ -94,8 +99,8 @@ class SentryApollo3InterceptorClientErrors { .setSocketPolicy(socketPolicy) .setResponseCode(httpStatusCode) - if (sendDefaultPii) { - response.addHeader("Set-Cookie", "Test") + if (includeCookies) { + response.addHeader("Set-Cookie", "theme=dark; Path=/") } server.enqueue(response) @@ -108,8 +113,8 @@ class SentryApollo3InterceptorClientErrors { captureFailedRequests = captureFailedRequests, failedRequestTargets = failedRequestTargets, ) - if (sendDefaultPii) { - builder.addHttpHeader("Cookie", "Test") + if (includeCookies) { + builder.addHttpHeader("Cookie", "theme=dark; sessionId=secret") } return builder.build() @@ -266,6 +271,153 @@ class SentryApollo3InterceptorClientErrors { ) } + @Test + fun `data collection can disable outgoing request body`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpBodies = setOf(HttpBodyType.INCOMING_RESPONSE) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals(193L, it.request!!.bodySize) + assertNull(it.request!!.data) + }, + any(), + ) + } + + @Test + fun `data collection can disable the GraphQL document independently`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.graphql.setDocument(false) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val body = it.request!!.data as String + assertFalse(body.contains("\"query\"")) + assertTrue(body.contains("\"variables\"")) + }, + any(), + ) + } + + @Test + fun `data collection can disable GraphQL variables independently`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.graphql.setVariables(false) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val body = it.request!!.data as String + assertTrue(body.contains("\"query\"")) + assertFalse(body.contains("\"variables\"")) + }, + any(), + ) + } + + @Test + fun `data collection can disable the GraphQL request body`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.graphql.setDocument(false) + dataCollection.graphql.setVariables(false) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { assertNull(it.request!!.data) }, + any(), + ) + } + + @Test + fun `data collection filters request headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("operation-name") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals( + "[Filtered]", + it.request!!.headers?.get("X-APOLLO-OPERATION-NAME"), + ) + }, + any(), + ) + } + + @Test + fun `data collection filters cookies`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk, includeCookies = true) { + dataCollection.cookies = KeyValueCollectionBehavior.denyList("theme") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("theme=[Filtered]; sessionId=[Filtered]", it.request!!.cookies) + assertEquals("theme=[Filtered]; Path=/", it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection can disable cookies`() { + val sut = + fixture.getSut( + responseBody = fixture.responseBodyNotOk, + sendDefaultPii = true, + includeCookies = true, + ) { + dataCollection.cookies = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertNull(it.request!!.cookies) + assertNull(it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection can disable request headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { assertTrue(it.request!!.headers!!.isEmpty()) }, + any(), + ) + } + @Test fun `capture errors with more request context if sendDefaultPii is enabled`() { val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) @@ -276,7 +428,7 @@ class SentryApollo3InterceptorClientErrors { check { val request = it.request!! - assertEquals("Test", request.cookies) + assertEquals("theme=dark; sessionId=secret", request.cookies) assertNotNull(request.headers) assertEquals("LaunchDetails", request.headers?.get("X-APOLLO-OPERATION-NAME")) }, @@ -304,6 +456,58 @@ class SentryApollo3InterceptorClientErrors { ) } + @Test + fun `data collection can disable incoming response body`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpBodies = emptySet() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val response = it.contexts.response!! + assertEquals(200, response.statusCode) + assertEquals(200, response.bodySize) + assertNull(response.data) + }, + any(), + ) + } + + @Test + fun `data collection filters response headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.denyList("content-length") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("[Filtered]", it.contexts.response!!.headers?.get("Content-Length")) + }, + any(), + ) + } + + @Test + fun `data collection can disable response headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { assertTrue(it.contexts.response!!.headers!!.isEmpty()) }, + any(), + ) + } + @Test fun `capture errors with more response context if sendDefaultPii is enabled`() { val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) @@ -314,7 +518,7 @@ class SentryApollo3InterceptorClientErrors { check { val response = it.contexts.response!! - assertEquals("Test", response.cookies) + assertEquals("theme=dark; Path=/", response.cookies) assertNotNull(response.headers) assertEquals(200, response.headers?.get("Content-Length")?.toInt()) }, diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorWithVariablesTest.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorWithVariablesTest.kt index 9d0028b5db7..a77c3b6ecd4 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorWithVariablesTest.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorWithVariablesTest.kt @@ -55,9 +55,9 @@ class SentryApollo3InterceptorWithVariablesTest { }""", socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, beforeSpan: BeforeSpanCallback? = null, + options: SentryOptions = SentryOptions().apply { dsn = "http://key@localhost/proj" }, ): ApolloClient { - whenever(scopes.options) - .thenReturn(SentryOptions().apply { dsn = "http://key@localhost/proj" }) + whenever(scopes.options).thenReturn(options) server.enqueue( MockResponse() @@ -91,6 +91,28 @@ class SentryApollo3InterceptorWithVariablesTest { ) } + @Test + fun `does not attach GraphQL variables when data collection disables them`() { + val options = + SentryOptions().apply { + dsn = "http://key@localhost/proj" + dataCollection.graphql.setVariables(false) + } + + executeQuery(fixture.getSut(options = options)) + + verify(fixture.scopes) + .captureTransaction( + check { + assertNull(it.spans.first().data?.get("variables")) + assertNotNull(it.spans.first().data?.get("operationId")) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `creates a span around the failed request`() { executeQuery(fixture.getSut(httpStatusCode = 403)) diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index fcf50564e5a..54ad1d50fdb 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -25,6 +25,8 @@ import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Mechanism import io.sentry.protocol.Request import io.sentry.protocol.Response +import io.sentry.util.CookieUtils +import io.sentry.util.GraphqlUtils import io.sentry.util.HttpUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.Platform @@ -158,7 +160,7 @@ constructor( operationType: String?, operationId: String?, ): ISpan { - val urlDetails = UrlUtils.parse(request.url) + val urlDetails = UrlUtils.parse(request.url, scopes.options.dataCollectionResolver) val method = request.method.name val operation = if (operationType != null) "http.graphql.$operationType" else "http.graphql" @@ -173,7 +175,9 @@ constructor( operationId?.let { setData("operationId", it) } - variables?.let { setData("variables", it) } + if (scopes.options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways) { + variables?.let { setData("variables", it) } + } setData(HTTP_METHOD_KEY, method.uppercase(Locale.ROOT)) } } @@ -228,7 +232,13 @@ constructor( span.finish() } - val breadcrumb = Breadcrumb.http(request.url, request.method.name, statusCode) + val breadcrumb = + Breadcrumb.http( + request.url, + request.method.name, + statusCode, + scopes.options.dataCollectionResolver, + ) request.body?.contentLength.ifHasValidLength { contentLength -> breadcrumb.setData("request_body_size", contentLength) @@ -260,6 +270,36 @@ constructor( private fun getHeader(key: String, headers: List): String? = headers.firstOrNull { it.name.equals(key, true) }?.value + private fun getRequestHeaders(headers: List): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val requestHeaders = mutableMapOf() + for (header in headers) { + requestHeaders[header.name] = header.value + } + return HttpUtils.filterHeaders( + requestHeaders, + scopes.options.dataCollectionResolver.httpRequestHeaders, + ) + .toMutableMap() + } + return getHeaders(headers) + } + + private fun getResponseHeaders(headers: List): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val responseHeaders = mutableMapOf() + for (header in headers) { + responseHeaders[header.name] = header.value + } + return HttpUtils.filterHeaders( + responseHeaders, + scopes.options.dataCollectionResolver.httpResponseHeaders, + ) + .toMutableMap() + } + return getHeaders(headers) + } + private fun getHeaders(headers: List): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII if (!scopes.options.isSendDefaultPii) { @@ -317,7 +357,7 @@ constructor( // url will be: https://api.github.com/users/getsentry/repos/ // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/ // but that's not possible - val urlDetails = UrlUtils.parse(request.url) + val urlDetails = UrlUtils.parse(request.url, scopes.options.dataCollectionResolver) // return if it's not a target match if (!PropagationTargetsUtils.contain(failedRequestTargets, urlDetails.urlOrFallback)) { @@ -351,46 +391,43 @@ constructor( val sentryRequest = Request().apply { urlDetails.applyToRequest(this) - // Cookie is only sent if isSendDefaultPii is enabled - cookies = - if (scopes.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null + cookies = CookieUtils.filterCookies(getHeader("Cookie", request.headers), scopes.options) method = request.method.name - headers = getHeaders(request.headers) + headers = getRequestHeaders(request.headers) apiTarget = "graphql" request.body?.let { bodySize = it.contentLength - val buffer = Buffer() - - try { - it.writeTo(buffer) - data = buffer.readUtf8() - } catch (e: Throwable) { - scopes.options.logger.log(SentryLevel.ERROR, "Error reading the request body.", e) - // continue because the response body alone can already give some insights - } finally { - buffer.close() + if (scopes.options.dataCollectionResolver.isOutgoingRequestBody) { + val buffer = Buffer() + + try { + it.writeTo(buffer) + data = GraphqlUtils.filterRequestBody(buffer.readUtf8(), scopes.options) + } catch (e: Throwable) { + scopes.options.logger.log(SentryLevel.ERROR, "Error reading the request body.", e) + // continue because the response body alone can already give some insights + } finally { + buffer.close() + } } } } val sentryResponse = Response().apply { - // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII cookies = - if (scopes.options.isSendDefaultPii) { - getHeader("Set-Cookie", response.headers) - } else { - null - } - headers = getHeaders(response.headers) + CookieUtils.filterSetCookie(getHeader("Set-Cookie", response.headers), scopes.options) + headers = getResponseHeaders(response.headers) statusCode = response.statusCode response.body?.buffer?.size?.ifHasValidLength { contentLength -> bodySize = contentLength } - data = body + if (scopes.options.dataCollectionResolver.isIncomingResponseBody) { + data = body + } } fingerprints.add(response.statusCode.toString()) diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4Interceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4Interceptor.kt index 5e0b882aad6..2481e2893d4 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4Interceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4Interceptor.kt @@ -35,11 +35,13 @@ constructor(@ApiStatus.Internal private val scopes: IScopes = ScopesAdapter.getI .addHttpHeader(OPERATION_NAME_HEADER_NAME, encodeHeaderValue(request.operation.name())) .addHttpHeader(OPERATION_TYPE_HEADER_NAME, encodeHeaderValue(operationType(request))) - request.scalarAdapters?.let { - builder.addHttpHeader( - VARIABLES_HEADER_NAME, - encodeHeaderValue(request.operation.variables(it).valueMap.toString()), - ) + if (scopes.options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways) { + request.scalarAdapters?.let { + builder.addHttpHeader( + VARIABLES_HEADER_NAME, + encodeHeaderValue(request.operation.variables(it).valueMap.toString()), + ) + } } return chain.proceed(builder.build()) diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApolloBuilderExtensions.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApolloBuilderExtensions.kt index 61ff468d265..51383d33ed7 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApolloBuilderExtensions.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApolloBuilderExtensions.kt @@ -13,7 +13,7 @@ fun ApolloClient.Builder.sentryTracing( failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS), beforeSpan: SentryApollo4HttpInterceptor.BeforeSpanCallback? = null, ): ApolloClient.Builder { - addInterceptor(SentryApollo4Interceptor()) + addInterceptor(SentryApollo4Interceptor(scopes)) addHttpInterceptor( SentryApollo4HttpInterceptor( scopes = scopes, diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt index 0572e4f1323..4c13e35a83e 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt @@ -8,7 +8,9 @@ import com.apollographql.apollo.api.http.HttpRequest import com.apollographql.apollo.api.http.HttpResponse import com.apollographql.apollo.exception.ApolloException import io.sentry.Hint +import io.sentry.HttpBodyType import io.sentry.IScopes +import io.sentry.KeyValueCollectionBehavior import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS @@ -85,7 +87,9 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( httpStatusCode: Int = 200, responseBody: String = responseBodyOk, sendDefaultPii: Boolean = false, + includeCookies: Boolean = sendDefaultPii, socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + configureOptions: SentryOptions.() -> Unit = {}, ): ApolloClient { SentryIntegrationPackageStorage.getInstance().clearStorage() @@ -97,6 +101,7 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( dsn = "https://key@sentry.io/proj" sdkVersion = SdkVersion("test", "1.2.3") isSendDefaultPii = sendDefaultPii + configureOptions() } ) } @@ -108,8 +113,8 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( .setSocketPolicy(socketPolicy) .setResponseCode(httpStatusCode) - if (sendDefaultPii) { - response.addHeader("Set-Cookie", "Test") + if (includeCookies) { + response.addHeader("Set-Cookie", "theme=dark; Path=/") } server.enqueue(response) @@ -122,8 +127,8 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( captureFailedRequests = captureFailedRequests, failedRequestTargets = failedRequestTargets, ) - if (sendDefaultPii) { - builder.addHttpHeader("Cookie", "Test") + if (includeCookies) { + builder.addHttpHeader("Cookie", "theme=dark; sessionId=secret") } return builder.build() @@ -280,6 +285,150 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( ) } + @Test + fun `data collection can disable outgoing request body`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpBodies = setOf(HttpBodyType.INCOMING_RESPONSE) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals(193L, it.request!!.bodySize) + assertNull(it.request!!.data) + }, + any(), + ) + } + + @Test + fun `data collection can disable the GraphQL document independently`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.graphql.setDocument(false) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val body = it.request!!.data as String + assertFalse(body.contains("\"query\"")) + assertTrue(body.contains("\"variables\"")) + }, + any(), + ) + } + + @Test + fun `data collection can disable GraphQL variables independently`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.graphql.setVariables(false) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val body = it.request!!.data as String + assertTrue(body.contains("\"query\"")) + assertFalse(body.contains("\"variables\"")) + }, + any(), + ) + } + + @Test + fun `data collection can disable the GraphQL request body`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.graphql.setDocument(false) + dataCollection.graphql.setVariables(false) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { assertNull(it.request!!.data) }, + any(), + ) + } + + @Test + fun `data collection filters cookies`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk, includeCookies = true) { + dataCollection.cookies = KeyValueCollectionBehavior.denyList("theme") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("theme=[Filtered]; sessionId=[Filtered]", it.request!!.cookies) + assertEquals("theme=[Filtered]; Path=/", it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection can disable cookies`() { + val sut = + fixture.getSut( + responseBody = fixture.responseBodyNotOk, + sendDefaultPii = true, + includeCookies = true, + ) { + dataCollection.cookies = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertNull(it.request!!.cookies) + assertNull(it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection filters request headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("accept") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("[Filtered]", it.request!!.headers?.get("Accept")) + }, + any(), + ) + } + + @Test + fun `data collection can disable request headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { assertTrue(it.request!!.headers!!.isEmpty()) }, + any(), + ) + } + @Test fun `capture errors with more request context if sendDefaultPii is enabled`() { val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) @@ -290,7 +439,7 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( check { val request = it.request!! - assertEquals("Test", request.cookies) + assertEquals("theme=dark; sessionId=secret", request.cookies) assertNotNull(request.headers) }, any(), @@ -317,6 +466,58 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( ) } + @Test + fun `data collection can disable incoming response body`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpBodies = emptySet() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val response = it.contexts.response!! + assertEquals(200, response.statusCode) + assertEquals(200, response.bodySize) + assertNull(response.data) + }, + any(), + ) + } + + @Test + fun `data collection filters response headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.denyList("content-length") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("[Filtered]", it.contexts.response!!.headers?.get("Content-Length")) + }, + any(), + ) + } + + @Test + fun `data collection can disable response headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { assertTrue(it.contexts.response!!.headers!!.isEmpty()) }, + any(), + ) + } + @Test fun `capture errors with more response context if sendDefaultPii is enabled`() { val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) @@ -327,7 +528,7 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( check { val response = it.contexts.response!! - assertEquals("Test", response.cookies) + assertEquals("theme=dark; Path=/", response.cookies) assertNotNull(response.headers) assertEquals(200, response.headers?.get("Content-Length")?.toInt()) }, diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsTest.kt index 2c5b23adc4f..654ff307eba 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsTest.kt @@ -23,6 +23,7 @@ import kotlin.reflect.KSuspendFunction1 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -69,9 +70,9 @@ abstract class SentryApollo4BuilderExtensionsTest( }""", socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, beforeSpan: BeforeSpanCallback? = null, + options: SentryOptions = SentryOptions().apply { dsn = "http://key@localhost/proj" }, ): ApolloClient { - whenever(scopes.options) - .thenReturn(SentryOptions().apply { dsn = "http://key@localhost/proj" }) + whenever(scopes.options).thenReturn(options) server.enqueue( MockResponse() @@ -105,6 +106,28 @@ abstract class SentryApollo4BuilderExtensionsTest( ) } + @Test + fun `does not attach GraphQL variables when data collection disables them`() { + val options = + SentryOptions().apply { + dsn = "http://key@localhost/proj" + dataCollection.graphql.setVariables(false) + } + + executeQuery(fixture.getSut(options = options)) + + verify(fixture.scopes) + .captureTransaction( + check { + assertNull(it.spans.first().data?.get("variables")) + assertNotNull(it.spans.first().data?.get("operationId")) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `creates span around failed request`() { executeQuery(fixture.getSut(httpStatusCode = 403)) diff --git a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt index e496d1055f3..cb7df6472dd 100644 --- a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt +++ b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt @@ -74,7 +74,9 @@ class SentryApolloInterceptor( val requestWithHeader = request.toBuilder().requestHeaders(headers).build() span.setData("operationId", requestWithHeader.operation.operationId()) - span.setData("variables", requestWithHeader.operation.variables().valueMap().toString()) + if (scopes.options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways) { + span.setData("variables", requestWithHeader.operation.variables().valueMap().toString()) + } chain.proceedAsync( requestWithHeader, @@ -196,7 +198,12 @@ class SentryApolloInterceptor( val httpRequest = httpResponse.request() val breadcrumb = - Breadcrumb.http(httpRequest.url().toString(), httpRequest.method(), httpResponse.code()) + Breadcrumb.http( + httpRequest.url().toString(), + httpRequest.method(), + httpResponse.code(), + scopes.options.dataCollectionResolver, + ) httpRequest.body()?.contentLength().ifHasValidLength { contentLength -> breadcrumb.setData("request_body_size", contentLength) diff --git a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt index aaf9b30b7f3..d43fe40c9e4 100644 --- a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt +++ b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt @@ -121,6 +121,24 @@ class SentryApolloInterceptorTest { ) } + @Test + fun `does not attach GraphQL variables when data collection disables them`() { + fixture.options.dataCollection.graphql.setVariables(false) + + executeQuery() + + verify(fixture.scopes) + .captureTransaction( + check { + assertNull(it.spans.first().data?.get("variables")) + assertNotNull(it.spans.first().data?.get("operationId")) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `creates a span around the failed request`() { executeQuery(fixture.getSut(httpStatusCode = 403)) diff --git a/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java index 9bca0955e40..4330a49e22d 100644 --- a/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java @@ -45,7 +45,7 @@ public void captureThrowable( final @NotNull Hint hint = new Hint(); setRequestDetailsOnEvent(scopes, exceptionDetails, event); - if (result != null && isAllowedToAttachBody(scopes)) { + if (result != null && isAllowedToAttachResponseBody(scopes)) { final @NotNull Response response = new Response(); final @NotNull Map responseBody = result.toSpecification(); response.setData(responseBody); @@ -55,10 +55,17 @@ public void captureThrowable( scopes.captureEvent(event, hint); } - private boolean isAllowedToAttachBody(final @NotNull IScopes scopes) { + private boolean isAllowedToAttachRequestBody(final @NotNull IScopes scopes) { final @NotNull SentryOptions options = scopes.getOptions(); - return options.isSendDefaultPii() - && !SentryOptions.RequestSize.NONE.equals(options.getMaxRequestBodySize()); + return options.getDataCollectionResolver().isGraphqlDocumentWithLegacyBodyGate() + || options.getDataCollectionResolver().isGraphqlVariablesWithLegacyBodyGate(); + } + + private boolean isAllowedToAttachResponseBody(final @NotNull IScopes scopes) { + return scopes + .getOptions() + .getDataCollectionResolver() + .isOutgoingResponseBodyWithLegacyBodyGate(); } private void setRequestDetailsOnEvent( @@ -80,20 +87,27 @@ private void setDetailsOnRequest( final @NotNull Request request) { request.setApiTarget("graphql"); - if (isAllowedToAttachBody(scopes) + if (isAllowedToAttachRequestBody(scopes) && (exceptionDetails.isSubscription() || captureRequestBodyForNonSubscriptions)) { final @NotNull Map data = new HashMap<>(); + final @NotNull SentryOptions options = scopes.getOptions(); - data.put("query", exceptionDetails.getQuery()); + if (options.getDataCollectionResolver().isGraphqlDocumentWithLegacyBodyGate()) { + data.put("query", exceptionDetails.getQuery()); + } - final @Nullable Map variables = exceptionDetails.getVariables(); - if (variables != null && !variables.isEmpty()) { - data.put("variables", variables); + if (options.getDataCollectionResolver().isGraphqlVariablesWithLegacyBodyGate()) { + final @Nullable Map variables = exceptionDetails.getVariables(); + if (variables != null && !variables.isEmpty()) { + data.put("variables", variables); + } } // for Spring HTTP this will be replaced by RequestBodyExtractingEventProcessor // for non subscription (websocket) errors - request.setData(data); + if (!data.isEmpty()) { + request.setData(data); + } } } diff --git a/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt index 759591d323c..3d367663a15 100644 --- a/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt +++ b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt @@ -12,8 +12,10 @@ import graphql.schema.GraphQLObjectType import graphql.schema.GraphQLScalarType import graphql.schema.GraphQLSchema import io.sentry.Hint +import io.sentry.HttpBodyType import io.sentry.IScope import io.sentry.IScopes +import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions @@ -221,6 +223,155 @@ class ExceptionReporterTest { ) } + @Test + fun `data collection ignores the legacy max request body size option`() { + val options = + SentryOptions().also { + it.maxRequestBodySize = SentryOptions.RequestSize.NONE + it.dataCollection.graphql.setDocument(true) + it.dataCollection.graphql.setVariables(true) + } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { + val data = it.request!!.data as Map + assertEquals(fixture.query, data["query"]) + assertEquals(fixture.variables, data["variables"]) + }, + any(), + ) + } + + @Test + fun `legacy options can disable outgoing response data`() { + val options = SentryOptions().also { it.maxRequestBodySize = SentryOptions.RequestSize.ALWAYS } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { assertNull(it.contexts.response) }, + any(), + ) + } + + @Test + fun `legacy request body size can disable outgoing response data`() { + val options = SentryOptions().also { it.isSendDefaultPii = true } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { assertNull(it.contexts.response) }, + any(), + ) + } + + @Test + fun `data collection response ignores legacy request body options`() { + val options = + SentryOptions().also { + it.maxRequestBodySize = SentryOptions.RequestSize.NONE + it.dataCollection.httpBodies = setOf(HttpBodyType.OUTGOING_RESPONSE) + } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { assertNotNull(it.contexts.response?.data) }, + any(), + ) + } + + @Test + fun `data collection can disable outgoing response data`() { + val options = fixture.defaultOptions.also { it.dataCollection.httpBodies = emptySet() } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { assertNull(it.contexts.response) }, + any(), + ) + } + + @Test + fun `data collection namespace default enables outgoing response data`() { + val options = + SentryOptions().also { + it.maxRequestBodySize = SentryOptions.RequestSize.NONE + it.dataCollection.cookies = KeyValueCollectionBehavior.off() + } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { assertNotNull(it.contexts.response?.data) }, + any(), + ) + } + @Test fun `does not attach query or variables if sendDefaultPii is false`() { val exceptionReporter = @@ -254,6 +405,114 @@ class ExceptionReporterTest { ) } + @Test + fun `data collection can disable the query independently`() { + val options = fixture.defaultOptions + options.dataCollection.graphql.setDocument(false) + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { + val data = it.request!!.data as Map + assertNull(data["query"]) + assertEquals(fixture.variables, data["variables"]) + }, + any(), + ) + } + + @Test + fun `data collection can disable variables independently`() { + val options = fixture.defaultOptions + options.dataCollection.graphql.setVariables(false) + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { + val data = it.request!!.data as Map + assertEquals(fixture.query, data["query"]) + assertNull(data["variables"]) + }, + any(), + ) + } + + @Test + fun `data collection can disable both query and variables`() { + val options = fixture.defaultOptions + options.dataCollection.graphql.setDocument(false) + options.dataCollection.graphql.setVariables(false) + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { assertNull(it.request!!.data) }, + any(), + ) + } + + @Test + fun `data collection namespace defaults enable query and variables`() { + val options = + SentryOptions().also { + it.maxRequestBodySize = SentryOptions.RequestSize.ALWAYS + it.dataCollection.cookies = KeyValueCollectionBehavior.off() + } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { + val data = it.request!!.data as Map + assertEquals(fixture.query, data["query"]) + assertEquals(fixture.variables, data["variables"]) + }, + any(), + ) + } + @Test fun `attaches query and variables if spring and subscription`() { val exceptionReporter = fixture.getSut(captureRequestBodyForNonSubscriptions = false) diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt index 2f559f804fc..95cdfb5fdae 100644 --- a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt +++ b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt @@ -24,6 +24,7 @@ import io.sentry.util.Platform import io.sentry.util.PropagationTargetsUtils import io.sentry.util.SpanUtils import io.sentry.util.TracingUtils +import io.sentry.util.UrlUtils import kotlinx.coroutines.withContext /** Configuration for the Sentry Ktor client plugin. */ @@ -98,20 +99,20 @@ public val SentryKtorClientPlugin: ClientPlugin = val requestSpanKey = AttributeKey("SentryRequestSpan") onRequest { request, _ -> + val effectiveScopes = if (forceScopes) scopes else Sentry.getCurrentScopes() request.attributes.put( requestStartTimestampKey, - (if (forceScopes) scopes else Sentry.getCurrentScopes()).options.dateProvider.now(), + effectiveScopes.options.dateProvider.now(), ) val parentSpan: ISpan? = if (forceScopes) scopes.getSpan() - else { - val currentScopes = Sentry.getCurrentScopes() - if (Platform.isAndroid()) currentScopes.transaction else currentScopes.span - } + else if (Platform.isAndroid()) effectiveScopes.transaction else effectiveScopes.span val spanOp = "http.client" - val spanDescription = "${request.method.value.toString()} ${request.url.buildString()}" + val rawUrl = request.url.buildString() + val urlDetails = UrlUtils.parse(rawUrl, effectiveScopes.options.dataCollectionResolver) + val spanDescription = "${request.method.value.toString()} ${urlDetails.urlOrFallback}" val span: ISpan? = parentSpan?.startChild(spanOp, spanDescription) if (span != null) { span.spanContext.origin = TRACE_ORIGIN @@ -120,13 +121,13 @@ public val SentryKtorClientPlugin: ClientPlugin = if ( !SpanUtils.isIgnored( - (if (forceScopes) scopes else Sentry.getCurrentScopes()).options.getIgnoredSpanOrigins(), + effectiveScopes.options.getIgnoredSpanOrigins(), TRACE_ORIGIN, ) ) { TracingUtils.traceIfAllowed( - if (forceScopes) scopes else Sentry.getCurrentScopes(), - request.url.buildString(), + effectiveScopes, + rawUrl, request.headers.getAll(BaggageHeader.BAGGAGE_HEADER), span, ) diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt index b56d3042de4..b8c1385ed92 100644 --- a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt +++ b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt @@ -16,6 +16,7 @@ import io.sentry.TypeCheckHint import io.sentry.exception.ExceptionMechanismException import io.sentry.exception.SentryHttpClientException import io.sentry.protocol.Mechanism +import io.sentry.util.CookieUtils import io.sentry.util.HttpUtils import io.sentry.util.UrlUtils @@ -25,7 +26,7 @@ internal object SentryKtorClientUtils { request: HttpRequest, response: HttpResponse, ) { - val urlDetails = UrlUtils.parse(request.url.toString()) + val urlDetails = UrlUtils.parse(request.url.toString(), scopes.options.dataCollectionResolver) val mechanism = Mechanism().apply { type = "SentryKtorClientPlugin" } val exception = @@ -36,19 +37,17 @@ internal object SentryKtorClientUtils { val sentryRequest = io.sentry.protocol.Request().apply { - // Cookie is only sent if isSendDefaultPii is enabled urlDetails.applyToRequest(this) - cookies = if (scopes.options.isSendDefaultPii) request.headers["Cookie"] else null + cookies = CookieUtils.filterCookies(request.headers["Cookie"], scopes.options) method = request.method.value - headers = getHeaders(scopes, request.headers) + headers = getRequestHeaders(scopes, request.headers) bodySize = request.content.contentLength } val sentryResponse = io.sentry.protocol.Response().apply { - // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII - cookies = if (scopes.options.isSendDefaultPii) response.headers["Set-Cookie"] else null - headers = getHeaders(scopes, response.headers) + cookies = CookieUtils.filterSetCookie(response.headers["Set-Cookie"], scopes.options) + headers = getResponseHeaders(scopes, response.headers) statusCode = response.status.value try { bodySize = response.bodyAsBytes().size.toLong() @@ -67,6 +66,32 @@ internal object SentryKtorClientUtils { scopes.captureEvent(event, hint) } + private fun getRequestHeaders(scopes: IScopes, headers: Headers): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val requestHeaders = + headers.toMap().mapValues { (_, values) -> values.joinToString(",") }.toMutableMap() + return HttpUtils.filterHeaders( + requestHeaders, + scopes.options.dataCollectionResolver.httpRequestHeaders, + ) + .toMutableMap() + } + return getHeaders(scopes, headers) + } + + private fun getResponseHeaders(scopes: IScopes, headers: Headers): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val responseHeaders = + headers.toMap().mapValues { (_, values) -> values.joinToString(",") }.toMutableMap() + return HttpUtils.filterHeaders( + responseHeaders, + scopes.options.dataCollectionResolver.httpResponseHeaders, + ) + .toMutableMap() + } + return getHeaders(scopes, headers) + } + private fun getHeaders(scopes: IScopes, headers: Headers): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII if (!scopes.options.isSendDefaultPii) { @@ -90,7 +115,12 @@ internal object SentryKtorClientUtils { endTimestamp: SentryDate?, ) { val breadcrumb = - Breadcrumb.http(request.url.toString(), request.method.value, response.status.value) + Breadcrumb.http( + request.url.toString(), + request.method.value, + response.status.value, + scopes.options.dataCollectionResolver, + ) breadcrumb.setData( SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, response.contentLength(), diff --git a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt index 976d3200e11..38ffe609b2c 100644 --- a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt +++ b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt @@ -14,6 +14,7 @@ import io.sentry.Hint import io.sentry.HttpStatusCodeRange import io.sentry.IScope import io.sentry.IScopes +import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.Sentry @@ -103,6 +104,7 @@ class SentryKtorClientPluginTest { MockResponse() .setBody(responseBody) .addHeader("myResponseHeader", "myValue") + .addHeader("Set-Cookie", "theme=dark; Path=/") .setSocketPolicy(socketPolicy) .setResponseCode(httpStatusCode) ) @@ -255,6 +257,163 @@ class SentryKtorClientPluginTest { verify(fixture.scopes, never()).captureEvent(any(), any()) } + @Test + fun `data collection filters cookies`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.cookies = KeyValueCollectionBehavior.denyList("theme") + }, + ) + + sut.get(fixture.server.url("/hello").toString()) { + headers["Cookie"] = "language=en; theme=dark; sessionId=secret" + } + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals( + "language=en; theme=[Filtered]; sessionId=[Filtered]", + it.request!!.cookies, + ) + assertEquals("theme=[Filtered]; Path=/", it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection can disable cookies`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + sendDefaultPii = true, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.cookies = KeyValueCollectionBehavior.off() + }, + ) + + sut.get(fixture.server.url("/hello").toString()) { headers["Cookie"] = "theme=dark" } + + verify(fixture.scopes) + .captureEvent( + check { + assertNull(it.request!!.cookies) + assertNull(it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection filters request headers`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("customer") + }, + ) + + sut.get(fixture.server.url("/hello").toString()) { + headers["content-type"] = "application/json" + headers["authorization"] = "Bearer token" + headers["x-customer"] = "customer value" + } + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("application/json", it.request!!.headers!!["content-type"]) + assertEquals("[Filtered]", it.request!!.headers!!["authorization"]) + assertEquals("[Filtered]", it.request!!.headers!!["x-customer"]) + }, + any(), + ) + } + + @Test + fun `data collection can disable request headers`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + }, + ) + + sut.get(fixture.server.url("/hello").toString()) { headers["myHeader"] = "myValue" } + + verify(fixture.scopes) + .captureEvent( + check { assertTrue(it.request!!.headers!!.isEmpty()) }, + any(), + ) + } + + @Test + fun `data collection filters response headers`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.httpHeaders.response = KeyValueCollectionBehavior.denyList("response") + }, + ) + + sut.get(fixture.server.url("/hello").toString()) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals( + "[Filtered]", + it.contexts.response!! + .headers!! + .entries + .firstOrNull { header -> + header.key.equals("myResponseHeader", ignoreCase = true) + } + ?.value, + ) + }, + any(), + ) + } + + @Test + fun `data collection can disable response headers`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.httpHeaders.response = KeyValueCollectionBehavior.off() + }, + ) + + sut.get(fixture.server.url("/hello").toString()) + + verify(fixture.scopes) + .captureEvent( + check { assertTrue(it.contexts.response!!.headers!!.isEmpty()) }, + any(), + ) + } + @Test fun `does not capture headers when sendDefaultPii is disabled`(): Unit = runBlocking { val sut = @@ -290,6 +449,16 @@ class SentryKtorClientPluginTest { assertTrue(httpClientSpan.isFinished) } + @Test + fun `span description excludes query parameters and fragment`(): Unit = runBlocking { + val sut = fixture.getSut() + sut.get(fixture.server.url("/hello?token=secret&page=1#results").toString()) + + val httpClientSpan = fixture.sentryTracer.children.first() + assertEquals("GET ${fixture.server.url("/hello")}", httpClientSpan.description) + assertNull(httpClientSpan.data[SpanDataConvention.HTTP_QUERY_KEY]) + } + @Test fun `finishes span setting throwable and status when request throws`(): Unit = runBlocking { val sut = fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_DURING_REQUEST_BODY) diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt index 7475f09443b..48dd678dd67 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt @@ -34,7 +34,7 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques private var method: String init { - val urlDetails = UrlUtils.parse(request.url.toString()) + val urlDetails = UrlUtils.parse(request.url.toString(), scopes.options.dataCollectionResolver) url = urlDetails.urlOrFallback method = request.method @@ -62,7 +62,7 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques * due to interceptors. */ fun setRequest(request: Request) { - val urlDetails = UrlUtils.parse(request.url.toString()) + val urlDetails = UrlUtils.parse(request.url.toString(), scopes.options.dataCollectionResolver) url = urlDetails.urlOrFallback val host: String = request.url.host @@ -78,8 +78,8 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques breadcrumb.setData("url", urlDetails.url!!) } breadcrumb.setData("method", method.uppercase()) - if (urlDetails.query != null) { - breadcrumb.setData("http.query", urlDetails.query!!) + urlDetails.query?.let { + breadcrumb.setData("http.query", it) } if (urlDetails.fragment != null) { breadcrumb.setData("http.fragment", urlDetails.fragment!!) diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index 7031be3b0b3..ed704966610 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -81,7 +81,7 @@ public open class SentryOkHttpInterceptor( override fun intercept(chain: Interceptor.Chain): Response { var request = chain.request() - val urlDetails = UrlUtils.parse(request.url.toString()) + val urlDetails = UrlUtils.parse(request.url.toString(), scopes.options.dataCollectionResolver) val url = urlDetails.urlOrFallback val method = request.method @@ -235,7 +235,13 @@ public open class SentryOkHttpInterceptor( startTimestamp: Long, networkDetailData: NetworkRequestData?, ) { - val breadcrumb = Breadcrumb.http(request.url.toString(), request.method, code) + val breadcrumb = + Breadcrumb.http( + request.url.toString(), + request.method, + code, + scopes.options.dataCollectionResolver, + ) // Track request and response body sizes for the breadcrumb request.body?.contentLength().ifHasValidLength { diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt index 2750fec4569..1be993c9544 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt @@ -7,6 +7,7 @@ import io.sentry.TypeCheckHint import io.sentry.exception.ExceptionMechanismException import io.sentry.exception.SentryHttpClientException import io.sentry.protocol.Mechanism +import io.sentry.util.CookieUtils import io.sentry.util.HttpUtils import io.sentry.util.UrlUtils import okhttp3.Headers @@ -21,7 +22,7 @@ internal object SentryOkHttpUtils { // url will be: https://api.github.com/users/getsentry/repos/ // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/ // but that's not possible - val urlDetails = UrlUtils.parse(request.url.toString()) + val urlDetails = UrlUtils.parse(request.url.toString(), scopes.options.dataCollectionResolver) val mechanism = Mechanism().apply { type = "SentryOkHttpInterceptor" } val exception = @@ -37,19 +38,17 @@ internal object SentryOkHttpUtils { val sentryRequest = io.sentry.protocol.Request().apply { urlDetails.applyToRequest(this) - // Cookie is only sent if isSendDefaultPii is enabled - cookies = if (scopes.options.isSendDefaultPii) request.headers["Cookie"] else null + cookies = CookieUtils.filterCookies(request.headers["Cookie"], scopes.options) method = request.method - headers = getHeaders(scopes, request.headers) + headers = getRequestHeaders(scopes, request.headers) request.body?.contentLength().ifHasValidLength { bodySize = it } } val sentryResponse = io.sentry.protocol.Response().apply { - // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII - cookies = if (scopes.options.isSendDefaultPii) response.headers["Set-Cookie"] else null - headers = getHeaders(scopes, response.headers) + cookies = CookieUtils.filterSetCookie(response.headers["Set-Cookie"], scopes.options) + headers = getResponseHeaders(scopes, response.headers) statusCode = response.code response.body?.contentLength().ifHasValidLength { bodySize = it } @@ -67,6 +66,42 @@ internal object SentryOkHttpUtils { } } + private fun getRequestHeaders( + scopes: IScopes, + requestHeaders: Headers, + ): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val headers = mutableMapOf() + for (i in 0 until requestHeaders.size) { + headers[requestHeaders.name(i)] = requestHeaders.value(i) + } + return HttpUtils.filterHeaders( + headers, + scopes.options.dataCollectionResolver.httpRequestHeaders, + ) + .toMutableMap() + } + return getHeaders(scopes, requestHeaders) + } + + private fun getResponseHeaders( + scopes: IScopes, + responseHeaders: Headers, + ): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val headers = mutableMapOf() + for (i in 0 until responseHeaders.size) { + headers[responseHeaders.name(i)] = responseHeaders.value(i) + } + return HttpUtils.filterHeaders( + headers, + scopes.options.dataCollectionResolver.httpResponseHeaders, + ) + .toMutableMap() + } + return getHeaders(scopes, responseHeaders) + } + private fun getHeaders(scopes: IScopes, requestHeaders: Headers): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII if (!scopes.options.isSendDefaultPii) { diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt index 5570e37787b..cadb615ad94 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt @@ -22,6 +22,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import okhttp3.Headers import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -382,6 +383,7 @@ class SentryOkHttpEventTest { val sut = fixture.getSut() val clientErrorResponse = mock() whenever(clientErrorResponse.request).thenReturn(fixture.mockRequest) + whenever(clientErrorResponse.headers).thenReturn(Headers.headersOf()) sut.setClientErrorResponse(clientErrorResponse) verify(fixture.scopes, never()).captureEvent(any(), any()) sut.finish() @@ -403,6 +405,7 @@ class SentryOkHttpEventTest { val sut = fixture.getSut(currentSpan = null) val clientErrorResponse = mock() whenever(clientErrorResponse.request).thenReturn(fixture.mockRequest) + whenever(clientErrorResponse.headers).thenReturn(Headers.headersOf()) sut.setClientErrorResponse(clientErrorResponse) verify(fixture.scopes, never()).captureEvent(any(), any()) sut.finish() diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index 9f7d8bc18fb..750406f3d22 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -8,6 +8,7 @@ import io.sentry.Hint import io.sentry.HttpStatusCodeRange import io.sentry.IScope import io.sentry.IScopes +import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.Sentry @@ -22,6 +23,7 @@ import io.sentry.TypeCheckHint import io.sentry.W3CTraceparentHeader import io.sentry.exception.SentryHttpClientException import io.sentry.mockServerRequestTimeoutMillis +import io.sentry.util.network.NetworkRequestData import java.io.IOException import java.util.concurrent.TimeUnit import kotlin.test.Test @@ -45,6 +47,7 @@ import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.SocketPolicy import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock @@ -324,6 +327,35 @@ class SentryOkHttpInterceptorTest { ) } + @Test + fun `data collection settings do not affect Session Replay network details`() { + val sut = + fixture.getSut( + optionsConfiguration = { + it.dataCollection.httpBodies = emptySet() + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + it.dataCollection.httpHeaders.response = KeyValueCollectionBehavior.off() + it.sessionReplay.setNetworkDetailAllowUrls(listOf(".*")) + } + ) + + val request = postRequest().newBuilder().addHeader("Accept", "application/json").build() + sut.newCall(request).execute() + + val hint = argumentCaptor() + verify(fixture.scopes).addBreadcrumb(any(), hint.capture()) + val networkDetails = + assertNotNull( + hint.firstValue.getAs( + TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS, + NetworkRequestData::class.java, + ) + ) + assertEquals("request-body", networkDetails.request?.body?.body) + assertEquals("application/json", networkDetails.request?.headers?.get("Accept")) + assertNotNull(networkDetails.response) + } + @SuppressWarnings("SwallowedException") @Test fun `adds breadcrumb when http calls results in exception`() { @@ -504,6 +536,26 @@ class SentryOkHttpInterceptorTest { ) } + @Test + fun `data collection filters failed request query parameters`() { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + optionsConfiguration = { it.dataCollection.setUserInfo(false) }, + ) + + sut.newCall(getRequest(url = "/hello?name=value&token=secret")).execute() + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("name=value&token=[Filtered]", it.request!!.queryString) + }, + any(), + ) + } + @Test fun `captures an error event with request body size`() { val sut = fixture.getSut(captureFailedRequests = true, httpStatusCode = 500) diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt index 0c03d396921..a11398fed57 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt @@ -2,6 +2,7 @@ package io.sentry.okhttp import io.sentry.Hint import io.sentry.IScopes +import io.sentry.KeyValueCollectionBehavior import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.TransactionContext @@ -36,12 +37,14 @@ class SentryOkHttpUtilsTest { responseBody: String = "success", socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, sendDefaultPii: Boolean = false, + configureOptions: SentryOptions.() -> Unit = {}, ): OkHttpClient { val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" setTracePropagationTargets(listOf(server.hostName)) isSendDefaultPii = sendDefaultPii + configureOptions() } whenever(scopes.options).thenReturn(options) @@ -53,7 +56,7 @@ class SentryOkHttpUtilsTest { MockResponse() .setBody(responseBody) .addHeader("myResponseHeader", "myValue") - .addHeader("Set-Cookie", "setCookie") + .addHeader("Set-Cookie", "theme=dark; Path=/") .setSocketPolicy(socketPolicy) .setResponseCode(httpStatusCode) ) @@ -66,7 +69,7 @@ class SentryOkHttpUtilsTest { private fun getRequest(url: String = "/hello"): Request = Request.Builder() .addHeader("myHeader", "myValue") - .addHeader("Cookie", "cookie") + .addHeader("Cookie", "theme=dark; sessionId=secret") .get() .url(fixture.server.url(url)) .build() @@ -121,6 +124,130 @@ class SentryOkHttpUtilsTest { ) } + @Test + fun `data collection filters request cookies`() { + val sut = fixture.getSut { + dataCollection.cookies = KeyValueCollectionBehavior.denyList("theme") + } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("theme=[Filtered]; sessionId=[Filtered]", it.request!!.cookies) + assertEquals("theme=[Filtered]; Path=/", it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection can disable cookies`() { + val sut = fixture.getSut { dataCollection.cookies = KeyValueCollectionBehavior.off() } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent( + check { + assertNull(it.request!!.cookies) + assertNull(it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection cookie defaults ignore sendDefaultPii`() { + val sut = fixture.getSut(sendDefaultPii = false) { dataCollection.setUserInfo(false) } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("theme=dark; sessionId=[Filtered]", it.request!!.cookies) + assertEquals("theme=dark; Path=/", it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection filters request headers`() { + val sut = fixture.getSut { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("myheader") + } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("[Filtered]", it.request!!.headers!!["myHeader"]) + assertEquals("[Filtered]", it.request!!.headers!!["Cookie"]) + }, + any(), + ) + } + + @Test + fun `data collection can disable request headers`() { + val sut = fixture.getSut { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent(check { assertTrue(it.request!!.headers!!.isEmpty()) }, any()) + } + + @Test + fun `data collection filters response headers`() { + val sut = fixture.getSut { + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.denyList("response") + } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("[Filtered]", it.contexts.response!!.headers!!["myResponseHeader"]) + assertEquals("[Filtered]", it.contexts.response!!.headers!!["Set-Cookie"]) + }, + any(), + ) + } + + @Test + fun `data collection can disable response headers`() { + val sut = fixture.getSut { + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.off() + } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent(check { assertTrue(it.contexts.response!!.headers!!.isEmpty()) }, any()) + } + @Test fun `captureClientError without sendDefaultPii does not send headers`() { val sut = fixture.getSut(sendDefaultPii = false) diff --git a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java index acd73bbec7c..520828c0a75 100644 --- a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java +++ b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java @@ -73,7 +73,8 @@ public Response execute(final @NotNull Request request, final @NotNull Request.O final @NotNull SpanOptions spanOptions = new SpanOptions(); spanOptions.setOrigin(TRACE_ORIGIN); ISpan span = activeSpan.startChild("http.client", null, spanOptions); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.url()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(request.url(), scopes.getOptions().getDataCollectionResolver()); final @NotNull String method = request.httpMethod().name(); span.setDescription(method + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)); @@ -158,7 +159,8 @@ private void addBreadcrumb(final @NotNull Request request, final @Nullable Respo Breadcrumb.http( request.url(), request.httpMethod().name(), - response != null ? response.status() : null); + response != null ? response.status() : null, + scopes.getOptions().getDataCollectionResolver()); breadcrumb.setData("request_body_size", request.body() != null ? request.body().length : 0); if (response != null && response.body() != null && response.body().length() != null) { breadcrumb.setData("response_body_size", response.body().length()); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java index 87088ae2377..8612f1132f8 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java @@ -9,6 +9,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.protocol.Request; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.StringUtils; import io.sentry.util.UrlUtils; @@ -91,7 +92,7 @@ private static Map collectHeaders( headers.put( headerName, toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( headerValues, headerName, null))); } catch (Throwable t) { options diff --git a/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java b/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java index 1ee536cb926..1904d2e5cf0 100644 --- a/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java @@ -3,6 +3,7 @@ import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryEvent; +import io.sentry.SentryOptions; import io.sentry.protocol.Request; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; @@ -20,9 +21,12 @@ final class SentryRequestHttpServletRequestProcessor implements EventProcessor { private final @NotNull HttpServletRequest httpRequest; + private final @NotNull SentryOptions options; - public SentryRequestHttpServletRequestProcessor(@NotNull HttpServletRequest httpRequest) { + public SentryRequestHttpServletRequestProcessor( + @NotNull HttpServletRequest httpRequest, @NotNull SentryOptions options) { this.httpRequest = Objects.requireNonNull(httpRequest, "httpRequest is required"); + this.options = Objects.requireNonNull(options, "options are required"); } // httpRequest.getRequestURL() returns StringBuffer which is considered an obsolete class. @@ -32,9 +36,11 @@ public SentryRequestHttpServletRequestProcessor(@NotNull HttpServletRequest http final Request sentryRequest = new Request(); sentryRequest.setMethod(httpRequest.getMethod()); final @NotNull UrlUtils.UrlDetails urlDetails = - UrlUtils.parse(httpRequest.getRequestURL().toString()); + UrlUtils.parse(httpRequest.getRequestURL().toString(), options.getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); - sentryRequest.setQueryString(httpRequest.getQueryString()); + sentryRequest.setQueryString( + UrlUtils.filterQueryParams( + httpRequest.getQueryString(), options.getDataCollectionResolver())); sentryRequest.setHeaders(resolveHeadersMap(httpRequest)); event.setRequest(sentryRequest); @@ -45,11 +51,15 @@ public SentryRequestHttpServletRequestProcessor(@NotNull HttpServletRequest http final @NotNull HttpServletRequest request) { final Map headersMap = new HashMap<>(); for (String headerName : Collections.list(request.getHeaderNames())) { - // do not copy personal information identifiable headers - if (!HttpUtils.containsSensitiveHeader(headerName.toUpperCase(Locale.ROOT))) { + if (options.getDataCollectionResolver().isDataCollectionConfigured() + || !HttpUtils.containsSensitiveHeader(headerName.toUpperCase(Locale.ROOT))) { headersMap.put(headerName, toString(request.getHeaders(headerName))); } } + if (options.getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, options.getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryServletRequestListener.java b/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryServletRequestListener.java index 9c8edeaf71c..909aee9b003 100644 --- a/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryServletRequestListener.java +++ b/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryServletRequestListener.java @@ -59,7 +59,8 @@ public void requestInitialized(@NotNull ServletRequestEvent servletRequestEvent) scopes.configureScope( scope -> { - scope.addEventProcessor(new SentryRequestHttpServletRequestProcessor(httpRequest)); + scope.addEventProcessor( + new SentryRequestHttpServletRequestProcessor(httpRequest, scopes.getOptions())); }); } } diff --git a/sentry-servlet-jakarta/src/test/kotlin/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessorTest.kt b/sentry-servlet-jakarta/src/test/kotlin/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessorTest.kt index 3e420aa1dfb..0aa9228530e 100644 --- a/sentry-servlet-jakarta/src/test/kotlin/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessorTest.kt +++ b/sentry-servlet-jakarta/src/test/kotlin/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessorTest.kt @@ -1,6 +1,7 @@ package io.sentry.servlet.jakarta import io.sentry.Hint +import io.sentry.KeyValueCollectionBehavior import io.sentry.SentryEvent import io.sentry.SentryOptions import jakarta.servlet.http.HttpServletRequest @@ -24,7 +25,7 @@ class SentryRequestHttpServletRequestProcessorTest { url = "http://example.com?param1=xyz", headers = mapOf("some-header" to "some-header value", "Accept" to "application/json"), ) - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, SentryOptions()) val event = SentryEvent() eventProcessor.process(event, Hint()) @@ -47,7 +48,7 @@ class SentryRequestHttpServletRequestProcessorTest { url = "http://example.com?param1=xyz", headers = mapOf("another-header" to listOf("another value", "another value2")), ) - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, SentryOptions()) val event = SentryEvent() eventProcessor.process(event, Hint()) @@ -63,7 +64,7 @@ class SentryRequestHttpServletRequestProcessorTest { mockRequest(url = "http://example.com?param1=xyz", headers = mapOf("Cookie" to "name=value")) val sentryOptions = SentryOptions() sentryOptions.isSendDefaultPii = false - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, sentryOptions) val event = SentryEvent() eventProcessor.process(event, Hint()) @@ -71,6 +72,51 @@ class SentryRequestHttpServletRequestProcessorTest { assertNotNull(event.request) { assertNull(it.cookies) } } + @Test + fun `data collection filters request headers`() { + val request = + mockRequest( + url = "http://example.com", + headers = + mapOf( + "content-type" to "application/json", + "authorization" to "Bearer token", + "x-customer" to "customer value", + ), + ) + val options = + SentryOptions().also { + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("customer") + } + val event = SentryEvent() + + SentryRequestHttpServletRequestProcessor(request, options).process(event, Hint()) + + assertEquals( + mapOf( + "content-type" to "application/json", + "authorization" to "[Filtered]", + "x-customer" to "[Filtered]", + ), + event.request!!.headers, + ) + } + + @Test + fun `data collection can disable request headers`() { + val request = + mockRequest(url = "http://example.com", headers = mapOf("content-type" to "application/json")) + val options = + SentryOptions().also { + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + } + val event = SentryEvent() + + SentryRequestHttpServletRequestProcessor(request, options).process(event, Hint()) + + assertEquals(emptyMap(), event.request!!.headers) + } + @Test fun `does not attach sensitive headers`() { val request = @@ -87,7 +133,7 @@ class SentryRequestHttpServletRequestProcessorTest { ) val sentryOptions = SentryOptions() sentryOptions.isSendDefaultPii = false - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, sentryOptions) val event = SentryEvent() eventProcessor.process(event, Hint()) diff --git a/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java b/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java index a005d50c0ad..789ed1b766f 100644 --- a/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java @@ -3,6 +3,7 @@ import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryEvent; +import io.sentry.SentryOptions; import io.sentry.protocol.Request; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; @@ -20,9 +21,12 @@ final class SentryRequestHttpServletRequestProcessor implements EventProcessor { private final @NotNull HttpServletRequest httpRequest; + private final @NotNull SentryOptions options; - public SentryRequestHttpServletRequestProcessor(@NotNull HttpServletRequest httpRequest) { + public SentryRequestHttpServletRequestProcessor( + @NotNull HttpServletRequest httpRequest, @NotNull SentryOptions options) { this.httpRequest = Objects.requireNonNull(httpRequest, "httpRequest is required"); + this.options = Objects.requireNonNull(options, "options are required"); } // httpRequest.getRequestURL() returns StringBuffer which is considered an obsolete class. @@ -32,9 +36,11 @@ public SentryRequestHttpServletRequestProcessor(@NotNull HttpServletRequest http final Request sentryRequest = new Request(); sentryRequest.setMethod(httpRequest.getMethod()); final @NotNull UrlUtils.UrlDetails urlDetails = - UrlUtils.parse(httpRequest.getRequestURL().toString()); + UrlUtils.parse(httpRequest.getRequestURL().toString(), options.getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); - sentryRequest.setQueryString(httpRequest.getQueryString()); + sentryRequest.setQueryString( + UrlUtils.filterQueryParams( + httpRequest.getQueryString(), options.getDataCollectionResolver())); sentryRequest.setHeaders(resolveHeadersMap(httpRequest)); event.setRequest(sentryRequest); @@ -45,11 +51,15 @@ public SentryRequestHttpServletRequestProcessor(@NotNull HttpServletRequest http final @NotNull HttpServletRequest request) { final Map headersMap = new HashMap<>(); for (String headerName : Collections.list(request.getHeaderNames())) { - // do not copy personal information identifiable headers - if (!HttpUtils.containsSensitiveHeader(headerName.toUpperCase(Locale.ROOT))) { + if (options.getDataCollectionResolver().isDataCollectionConfigured() + || !HttpUtils.containsSensitiveHeader(headerName.toUpperCase(Locale.ROOT))) { headersMap.put(headerName, toString(request.getHeaders(headerName))); } } + if (options.getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, options.getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-servlet/src/main/java/io/sentry/servlet/SentryServletRequestListener.java b/sentry-servlet/src/main/java/io/sentry/servlet/SentryServletRequestListener.java index 0a2a2f5d230..1874cacc66b 100644 --- a/sentry-servlet/src/main/java/io/sentry/servlet/SentryServletRequestListener.java +++ b/sentry-servlet/src/main/java/io/sentry/servlet/SentryServletRequestListener.java @@ -59,7 +59,8 @@ public void requestInitialized(@NotNull ServletRequestEvent servletRequestEvent) scopes.configureScope( scope -> { - scope.addEventProcessor(new SentryRequestHttpServletRequestProcessor(httpRequest)); + scope.addEventProcessor( + new SentryRequestHttpServletRequestProcessor(httpRequest, scopes.getOptions())); }); } } diff --git a/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt b/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt index a42a8ebb39b..c3861ed145e 100644 --- a/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt +++ b/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt @@ -1,6 +1,7 @@ package io.sentry.servlet import io.sentry.Hint +import io.sentry.KeyValueCollectionBehavior import io.sentry.SentryEvent import io.sentry.SentryOptions import java.net.URI @@ -21,7 +22,7 @@ class SentryRequestHttpServletRequestProcessorTest { .header("some-header", "some-header value") .accept("application/json") .buildRequest(MockServletContext()) - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, SentryOptions()) val event = SentryEvent() eventProcessor.process(event, Hint()) @@ -37,6 +38,33 @@ class SentryRequestHttpServletRequestProcessorTest { assertEquals("param1=xyz", eventRequest.queryString) } + @Test + fun `data collection filters query parameters`() { + val request = + MockMvcRequestBuilders.get(URI.create("http://example.com?name=value&token=secret")) + .buildRequest(MockServletContext()) + val options = SentryOptions().also { it.dataCollection.setUserInfo(false) } + val event = SentryEvent() + + SentryRequestHttpServletRequestProcessor(request, options).process(event, Hint()) + + assertEquals("name=value&token=[Filtered]", event.request!!.queryString) + } + + @Test + fun `data collection can disable query parameters`() { + val request = + MockMvcRequestBuilders.get(URI.create("http://example.com?name=value")) + .buildRequest(MockServletContext()) + val options = + SentryOptions().also { it.dataCollection.urlQueryParams = KeyValueCollectionBehavior.off() } + val event = SentryEvent() + + SentryRequestHttpServletRequestProcessor(request, options).process(event, Hint()) + + assertNull(event.request!!.queryString) + } + @Test fun `attaches header with multiple values`() { val request = @@ -44,7 +72,7 @@ class SentryRequestHttpServletRequestProcessorTest { .header("another-header", "another value") .header("another-header", "another value2") .buildRequest(MockServletContext()) - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, SentryOptions()) val event = SentryEvent() eventProcessor.process(event, Hint()) @@ -62,7 +90,7 @@ class SentryRequestHttpServletRequestProcessorTest { .buildRequest(MockServletContext()) val sentryOptions = SentryOptions() sentryOptions.isSendDefaultPii = false - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, sentryOptions) val event = SentryEvent() eventProcessor.process(event, Hint()) @@ -70,6 +98,49 @@ class SentryRequestHttpServletRequestProcessorTest { assertNotNull(event.request) { assertNull(it.cookies) } } + @Test + fun `data collection filters request headers`() { + val request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header("content-type", "application/json") + .header("authorization", "Bearer token") + .header("x-customer", "customer value") + .buildRequest(MockServletContext()) + val options = + SentryOptions().also { + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("customer") + } + val event = SentryEvent() + + SentryRequestHttpServletRequestProcessor(request, options).process(event, Hint()) + + assertEquals( + mapOf( + "Content-Type" to "application/json", + "authorization" to "[Filtered]", + "x-customer" to "[Filtered]", + ), + event.request!!.headers, + ) + } + + @Test + fun `data collection can disable request headers`() { + val request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header("content-type", "application/json") + .buildRequest(MockServletContext()) + val options = + SentryOptions().also { + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + } + val event = SentryEvent() + + SentryRequestHttpServletRequestProcessor(request, options).process(event, Hint()) + + assertEquals(emptyMap(), event.request!!.headers) + } + @Test fun `does not attach sensitive headers`() { val request = @@ -82,7 +153,7 @@ class SentryRequestHttpServletRequestProcessorTest { .buildRequest(MockServletContext()) val sentryOptions = SentryOptions() sentryOptions.isSendDefaultPii = false - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, sentryOptions) val event = SentryEvent() eventProcessor.process(event, Hint()) diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java index 54ad7602ae0..44ae584ec17 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java @@ -23,7 +23,7 @@ public HttpServletRequestSentryUserProvider(final @NotNull SentryOptions options @Override public @Nullable User provideUser() { - if (options.isSendDefaultPii()) { + if (options.getDataCollectionResolver().isUserInfo()) { final RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); if (requestAttributes instanceof ServletRequestAttributes) { final ServletRequestAttributes servletRequestAttributes = diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java index aba0ae808d1..c3d96eb8115 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java @@ -6,6 +6,7 @@ import io.sentry.SentryLevel; import io.sentry.protocol.Request; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; import io.sentry.util.UrlUtils; @@ -37,19 +38,30 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { final Request sentryRequest = new Request(); sentryRequest.setMethod(httpRequest.getMethod()); final @NotNull UrlUtils.UrlDetails urlDetails = - UrlUtils.parse(httpRequest.getRequestURL().toString()); + UrlUtils.parse( + httpRequest.getRequestURL().toString(), + scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); - sentryRequest.setQueryString(httpRequest.getQueryString()); + sentryRequest.setQueryString( + UrlUtils.filterQueryParams( + httpRequest.getQueryString(), scopes.getOptions().getDataCollectionResolver())); final @NotNull List additionalSecurityCookieNames = extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); - if (scopes.getOptions().isSendDefaultPii()) { - String cookieName = HttpUtils.COOKIE_HEADER_NAME; - final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( - httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames); - sentryRequest.setCookies(toString(filteredHeaders)); + final @NotNull String cookieName = CookieUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + CookieUtils.filterCookiesFromHeader( + httpRequest.getHeaders(cookieName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + additionalSecurityCookieNames))); + } else if (scopes.getOptions().isSendDefaultPii()) { + sentryRequest.setCookies( + toString( + CookieUtils.filterOutSecurityCookiesFromHeader( + httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames))); } return sentryRequest; } @@ -60,15 +72,19 @@ Map resolveHeadersMap( final @NotNull List additionalSecurityCookieNames) { final Map headersMap = new HashMap<>(); for (String headerName : Collections.list(request.getHeaderNames())) { - // do not copy personal information identifiable headers - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( request.getHeaders(headerName), headerName, additionalSecurityCookieNames); headersMap.put(headerName, toString(filteredHeaders)); } } + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, scopes.getOptions().getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java index 38bc4379088..bf2c431a179 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java @@ -110,7 +110,7 @@ private void configureScope( private @NotNull HttpServletRequest resolveHttpServletRequest( final @NotNull IScopes scopes, final @NotNull HttpServletRequest request) { - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isIncomingRequestBody() && qualifiesForCaching(request, scopes.getOptions().getMaxRequestBodySize())) { return new ContentCachingRequestWrapper(request, 0); } @@ -155,7 +155,7 @@ public RequestBodyExtractingEventProcessor( @Override public @NotNull SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { if (event.getRequest() != null - && options.isSendDefaultPii() + && options.getDataCollectionResolver().isIncomingRequestBody() && qualifiesForCaching(request, options.getMaxRequestBodySize())) { event.getRequest().setData(requestPayloadExtractor.extract(request, options)); } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java index b7e226929a5..da9d2f0f77e 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java @@ -46,7 +46,7 @@ protected void doFilterInternal( for (final SentryUserProvider provider : sentryUserProviders) { apply(user, provider.provideUser()); } - if (scopes.getOptions().isSendDefaultPii()) { + if (scopes.getOptions().getDataCollectionResolver().isUserInfo()) { if (IpAddressUtils.isDefault(user.getIpAddress())) { // unset {{auto}} as it would set the server's ip address as a user ip address user.setIpAddress(null); diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SpringSecuritySentryUserProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SpringSecuritySentryUserProvider.java index 164a43c5bd2..ff3f118898a 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SpringSecuritySentryUserProvider.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SpringSecuritySentryUserProvider.java @@ -22,7 +22,7 @@ public SpringSecuritySentryUserProvider(final @NotNull SentryOptions options) { @Override public @Nullable User provideUser() { - if (options.isSendDefaultPii()) { + if (options.getDataCollectionResolver().isUserInfo()) { final SecurityContext context = SecurityContextHolder.getContext(); if (context != null && context.getAuthentication() != null) { final User user = new User(); diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java index 50a8d0539b0..46a31245ba1 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java @@ -63,7 +63,9 @@ public SentrySpanClientHttpRequestInterceptor( final ISpan span = activeSpan.startChild("http.client", null, spanOptions); final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.getURI().toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse( + request.getURI().toString(), scopes.getOptions().getDataCollectionResolver()); span.setDescription(methodName + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, methodName.toUpperCase(Locale.ROOT)); urlDetails.applyToSpan(span); @@ -135,7 +137,11 @@ private void addBreadcrumb( final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; final Breadcrumb breadcrumb = - Breadcrumb.http(request.getURI().toString(), methodName, responseStatusCode); + Breadcrumb.http( + request.getURI().toString(), + methodName, + responseStatusCode, + scopes.getOptions().getDataCollectionResolver()); breadcrumb.setData("request_body_size", body.length); final Hint hint = new Hint(); diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java index 942cda241b7..df3cd548218 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java @@ -46,10 +46,14 @@ public SentrySpanClientWebRequestFilter(final @NotNull IScopes scopes) { final @NotNull SpanOptions spanOptions = new SpanOptions(); spanOptions.setOrigin(TRACE_ORIGIN); final ISpan span = activeSpan.startChild("http.client", null, spanOptions); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.url().toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(request.url().toString(), scopes.getOptions().getDataCollectionResolver()); final @NotNull String method = request.method().name(); span.setDescription(method + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)); + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + urlDetails.applyToSpan(span); + } final @NotNull ClientRequest modifiedRequest = maybeAddTracingHeaders(request, span); @@ -115,7 +119,8 @@ private void addBreadcrumb( Breadcrumb.http( request.url().toString(), request.method().name(), - response != null ? response.statusCode().value() : null); + response != null ? response.statusCode().value() : null, + scopes.getOptions().getDataCollectionResolver()); final Hint hint = new Hint(); hint.set(SPRING_EXCHANGE_FILTER_REQUEST, request); diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java index 0b41974a69d..4dd05110bbf 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java @@ -96,7 +96,13 @@ protected void doFirst( hint.set(WEBFLUX_FILTER_RESPONSE, response); final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; - requestScopes.addBreadcrumb(Breadcrumb.http(request.getURI().toString(), methodName), hint); + final @NotNull Breadcrumb breadcrumb = + Breadcrumb.http( + request.getURI().toString(), + methodName, + null, + requestScopes.getOptions().getDataCollectionResolver()); + requestScopes.addBreadcrumb(breadcrumb, hint); requestScopes.configureScope( scope -> scope.setRequest(sentryRequestResolver.resolveSentryRequest(request))); } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java index 3d6857cb648..3a4366d481b 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java @@ -3,6 +3,7 @@ import com.jakewharton.nopen.annotation.Open; import io.sentry.IScopes; import io.sentry.protocol.Request; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; import io.sentry.util.UrlUtils; @@ -32,15 +33,23 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { httpRequest.getMethod() != null ? httpRequest.getMethod().name() : "unknown"; sentryRequest.setMethod(methodName); final @NotNull URI uri = httpRequest.getURI(); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(uri.toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(uri.toString(), scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); - if (scopes.getOptions().isSendDefaultPii()) { - String headerName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String headerName = CookieUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { sentryRequest.setCookies( toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterCookiesFromHeader( + httpRequest.getHeaders().get(headerName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + Collections.emptyList()))); + } else if (scopes.getOptions().isSendDefaultPii()) { + sentryRequest.setCookies( + toString( + CookieUtils.filterOutSecurityCookiesFromHeader( httpRequest.getHeaders().get(headerName), headerName, Collections.emptyList()))); } return sentryRequest; @@ -50,17 +59,21 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { Map resolveHeadersMap(final HttpHeaders request) { final Map headersMap = new HashMap<>(); for (Map.Entry> entry : request.headerSet()) { - // do not copy personal information identifiable headers String headerName = entry.getKey(); - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { headersMap.put( headerName, toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( entry.getValue(), headerName, Collections.emptyList()))); } } + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, scopes.getOptions().getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/HttpServletRequestSentryUserProviderTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/HttpServletRequestSentryUserProviderTest.kt index 5254270a05c..16bddd0c27e 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/HttpServletRequestSentryUserProviderTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/HttpServletRequestSentryUserProviderTest.kt @@ -45,6 +45,43 @@ class HttpServletRequestSentryUserProviderTest { assertEquals("janesmith", result.username) } + @Test + fun `when user info is disabled, does not attach user data`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = + SentryOptions().apply { + isSendDefaultPii = true + dataCollection.setUserInfo(false) + } + val result = HttpServletRequestSentryUserProvider(options).provideUser() + + assertNull(result) + } + + @Test + fun `when user info is enabled, attaches user data`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = + SentryOptions().apply { + isSendDefaultPii = false + dataCollection.setUserInfo(true) + } + val result = HttpServletRequestSentryUserProvider(options).provideUser() + + assertNotNull(result) + assertEquals("janesmith", result.username) + } + @Test fun `when sendDefaultPii is set to false, does not attach user data Sentry Event`() { val principal = mock() diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt index 5a83c9d72a4..b1ea92766b1 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt @@ -1,9 +1,11 @@ package io.sentry.spring7 import io.sentry.Breadcrumb +import io.sentry.HttpBodyType import io.sentry.IScope import io.sentry.IScopes import io.sentry.ISentryLifecycleToken +import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions @@ -203,6 +205,30 @@ class SentrySpringFilterTest { assertNotNull(fixture.scope.request) { assertNull(it.cookies) } } + @Test + fun `data collection filters request headers`() { + val sentryOptions = + SentryOptions().apply { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("customer") + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header("content-type", "application/json") + .header("authorization", "Bearer token") + .header("x-customer", "customer value") + .buildRequest(MockServletContext()), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertEquals("application/json", fixture.scope.request!!.headers!!["Content-Type"]) + assertEquals("[Filtered]", fixture.scope.request!!.headers!!["authorization"]) + assertEquals("[Filtered]", fixture.scope.request!!.headers!!["x-customer"]) + } + @Test fun `when sendDefaultPii is set to false, does not attach sensitive headers`() { val sentryOptions = SentryOptions().apply { isSendDefaultPii = false } @@ -318,6 +344,52 @@ class SentrySpringFilterTest { } } + @Test + fun `data collection can enable request body when sendDefaultPii is false`() { + val options = + SentryOptions().apply { + isSendDefaultPii = false + maxRequestBodySize = SMALL + dataCollection.httpBodies = setOf(HttpBodyType.INCOMING_REQUEST) + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.post(URI.create("http://example.com")) + .content("xxx") + .contentType("application/json") + .buildRequest(MockServletContext()), + options = options, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(check { assertTrue(it is ContentCachingRequestWrapper) }, any()) + } + + @Test + fun `data collection can disable request body when sendDefaultPii is true`() { + val options = + SentryOptions().apply { + isSendDefaultPii = true + maxRequestBodySize = SMALL + dataCollection.httpBodies = emptySet() + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.post(URI.create("http://example.com")) + .content("xxx") + .contentType("application/json") + .buildRequest(MockServletContext()), + options = options, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(check { assertFalse(it is ContentCachingRequestWrapper) }, any()) + } + private fun servletContextWithCustomCookieName(name: String): ServletContext = MockServletContext().also { it.sessionCookieConfig.name = name } } diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt index 6284e8241ae..92327456e13 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt @@ -23,9 +23,14 @@ class SentryUserFilterTest { fun getSut( isSendDefaultPii: Boolean = false, + userInfo: Boolean? = null, userProviders: List, ): SentryUserFilter { - val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + val options = + SentryOptions().apply { + this.isSendDefaultPii = isSendDefaultPii + userInfo?.let { dataCollection.setUserInfo(it) } + } whenever(scopes.options).thenReturn(options) return SentryUserFilter(scopes, userProviders) } @@ -76,7 +81,7 @@ class SentryUserFilterTest { } @Test - fun `merges user#others with existing user#others set on SentryEvent`() { + fun `merges user#data with existing user#data set on SentryEvent`() { val filter = fixture.getSut( userProviders = @@ -118,6 +123,34 @@ class SentryUserFilterTest { verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) } + @Test + fun `when user info is disabled, preserves auto ip from a custom provider`() { + val filter = + fixture.getSut( + isSendDefaultPii = true, + userInfo = false, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "{{auto}}" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertEquals("{{auto}}", it.ipAddress) }) + } + + @Test + fun `when user info is enabled, removes auto ip from a custom provider`() { + val filter = + fixture.getSut( + isSendDefaultPii = false, + userInfo = true, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "{{auto}}" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) + } + private fun assertEquals(user1: User, user2: User) { assertEquals(user1.username, user2.username) assertEquals(user1.id, user2.id) diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringSecuritySentryUserProviderTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringSecuritySentryUserProviderTest.kt index 6330405999c..ca931ce3b8f 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringSecuritySentryUserProviderTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringSecuritySentryUserProviderTest.kt @@ -16,8 +16,13 @@ class SpringSecuritySentryUserProviderTest { fun getSut( isSendDefaultPii: Boolean = true, username: String? = null, + userInfo: Boolean? = null, ): SpringSecuritySentryUserProvider { - val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + val options = + SentryOptions().apply { + this.isSendDefaultPii = isSendDefaultPii + userInfo?.let { dataCollection.setUserInfo(it) } + } val securityContext = mock() if (username != null) { val authentication = mock() @@ -47,6 +52,20 @@ class SpringSecuritySentryUserProviderTest { assertNull(user) } + @Test + fun `when user info is disabled, returns null even if sendDefaultPii is true`() { + val provider = fixture.getSut(isSendDefaultPii = true, username = "name", userInfo = false) + + assertNull(provider.provideUser()) + } + + @Test + fun `when user info is enabled, returns user even if sendDefaultPii is false`() { + val provider = fixture.getSut(isSendDefaultPii = false, username = "name", userInfo = true) + + assertNotNull(provider.provideUser()) { assertEquals("name", it.username) } + } + @Test fun `when send default pii is set to true and security context is not set, returns null`() { val provider = fixture.getSut(true) diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt index bb14538d921..c6c65b560db 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt @@ -270,7 +270,7 @@ class SentryWebFluxTracingFilterTest { verify(fixture.chain).filter(fixture.exchange) verify(fixture.scopes, times(2)).isEnabled - verify(fixture.scopes, times(4)).options + verify(fixture.scopes, times(5)).options verify(fixture.scopes).continueTrace(anyOrNull(), anyOrNull()) verify(fixture.scopes).addBreadcrumb(any(), any()) verify(fixture.scopes).configureScope(any()) diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt index ef1f12aeecf..e9a33755334 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt @@ -12,6 +12,7 @@ import io.sentry.IProfileConverter import io.sentry.IScopes import io.sentry.ITransportFactory import io.sentry.Integration +import io.sentry.KeyValueCollectionBehavior import io.sentry.NoOpContinuousProfiler import io.sentry.NoOpProfileConverter import io.sentry.NoOpTransportFactory @@ -306,6 +307,35 @@ class SentryAutoConfigurationTest { } } + @Test + fun `data collection key value properties are applied to SentryOptions`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.data-collection.cookies.mode=off", + "sentry.data-collection.url-query-params.mode=allow-list", + "sentry.data-collection.url-query-params.terms=page,sort", + "sentry.data-collection.http-headers.request.mode=deny-list", + "sentry.data-collection.http-headers.request.terms=forwarded,-ip", + "sentry.data-collection.http-headers.response.mode=allow-list", + "sentry.data-collection.http-headers.response.terms=content-type,x-request-id", + ) + .run { + val dataCollection = it.getBean(SentryProperties::class.java).dataCollection + assertThat(dataCollection.cookies!!.mode).isEqualTo(KeyValueCollectionBehavior.Mode.OFF) + assertThat(dataCollection.urlQueryParams!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(dataCollection.urlQueryParams!!.terms).containsExactly("page", "sort") + assertThat(dataCollection.httpHeaders.request!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) + assertThat(dataCollection.httpHeaders.request!!.terms).containsExactly("forwarded", "-ip") + assertThat(dataCollection.httpHeaders.response!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(dataCollection.httpHeaders.response!!.terms) + .containsExactly("content-type", "x-request-id") + } + } + @Test fun `when tracePropagationTargets are not set, default is returned`() { contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt index 91677d16b4e..b3abc45c4b1 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt @@ -13,6 +13,7 @@ import io.sentry.IProfileConverter import io.sentry.IScopes import io.sentry.ITransportFactory import io.sentry.Integration +import io.sentry.KeyValueCollectionBehavior import io.sentry.NoOpContinuousProfiler import io.sentry.NoOpProfileConverter import io.sentry.NoOpTransportFactory @@ -314,6 +315,35 @@ class SentryAutoConfigurationTest { } } + @Test + fun `data collection key value properties are applied to SentryOptions`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.data-collection.cookies.mode=off", + "sentry.data-collection.url-query-params.mode=allow-list", + "sentry.data-collection.url-query-params.terms=page,sort", + "sentry.data-collection.http-headers.request.mode=deny-list", + "sentry.data-collection.http-headers.request.terms=forwarded,-ip", + "sentry.data-collection.http-headers.response.mode=allow-list", + "sentry.data-collection.http-headers.response.terms=content-type,x-request-id", + ) + .run { + val dataCollection = it.getBean(SentryProperties::class.java).dataCollection + assertThat(dataCollection.cookies!!.mode).isEqualTo(KeyValueCollectionBehavior.Mode.OFF) + assertThat(dataCollection.urlQueryParams!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(dataCollection.urlQueryParams!!.terms).containsExactly("page", "sort") + assertThat(dataCollection.httpHeaders.request!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) + assertThat(dataCollection.httpHeaders.request!!.terms).containsExactly("forwarded", "-ip") + assertThat(dataCollection.httpHeaders.response!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(dataCollection.httpHeaders.response!!.terms) + .containsExactly("content-type", "x-request-id") + } + } + @Test fun `when tracePropagationTargets are not set, default is returned`() { contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt index d9e598d0473..834440faf1c 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt @@ -13,6 +13,7 @@ import io.sentry.IProfileConverter import io.sentry.IScopes import io.sentry.ITransportFactory import io.sentry.Integration +import io.sentry.KeyValueCollectionBehavior import io.sentry.NoOpContinuousProfiler import io.sentry.NoOpProfileConverter import io.sentry.NoOpTransportFactory @@ -312,6 +313,35 @@ class SentryAutoConfigurationTest { } } + @Test + fun `data collection key value properties are applied to SentryOptions`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.data-collection.cookies.mode=off", + "sentry.data-collection.url-query-params.mode=allow-list", + "sentry.data-collection.url-query-params.terms=page,sort", + "sentry.data-collection.http-headers.request.mode=deny-list", + "sentry.data-collection.http-headers.request.terms=forwarded,-ip", + "sentry.data-collection.http-headers.response.mode=allow-list", + "sentry.data-collection.http-headers.response.terms=content-type,x-request-id", + ) + .run { + val dataCollection = it.getBean(SentryProperties::class.java).dataCollection + assertThat(dataCollection.cookies!!.mode).isEqualTo(KeyValueCollectionBehavior.Mode.OFF) + assertThat(dataCollection.urlQueryParams!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(dataCollection.urlQueryParams!!.terms).containsExactly("page", "sort") + assertThat(dataCollection.httpHeaders.request!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) + assertThat(dataCollection.httpHeaders.request!!.terms).containsExactly("forwarded", "-ip") + assertThat(dataCollection.httpHeaders.response!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(dataCollection.httpHeaders.response!!.terms) + .containsExactly("content-type", "x-request-id") + } + } + @Test fun `when tracePropagationTargets are not set, default is returned`() { dsnEnabledRunner.run { diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/HttpServletRequestSentryUserProvider.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/HttpServletRequestSentryUserProvider.java index 6174da0dc5f..b7f4646b4a8 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/HttpServletRequestSentryUserProvider.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/HttpServletRequestSentryUserProvider.java @@ -23,7 +23,7 @@ public HttpServletRequestSentryUserProvider(final @NotNull SentryOptions options @Override public @Nullable User provideUser() { - if (options.isSendDefaultPii()) { + if (options.getDataCollectionResolver().isUserInfo()) { final RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); if (requestAttributes instanceof ServletRequestAttributes) { final ServletRequestAttributes servletRequestAttributes = diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java index 4bb2ad312bb..d4f69cb8714 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java @@ -6,6 +6,7 @@ import io.sentry.SentryLevel; import io.sentry.protocol.Request; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; import io.sentry.util.UrlUtils; @@ -37,19 +38,30 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { final Request sentryRequest = new Request(); sentryRequest.setMethod(httpRequest.getMethod()); final @NotNull UrlUtils.UrlDetails urlDetails = - UrlUtils.parse(httpRequest.getRequestURL().toString()); + UrlUtils.parse( + httpRequest.getRequestURL().toString(), + scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); - sentryRequest.setQueryString(httpRequest.getQueryString()); + sentryRequest.setQueryString( + UrlUtils.filterQueryParams( + httpRequest.getQueryString(), scopes.getOptions().getDataCollectionResolver())); final @NotNull List additionalSecurityCookieNames = extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); - if (scopes.getOptions().isSendDefaultPii()) { - String cookieName = HttpUtils.COOKIE_HEADER_NAME; - final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( - httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames); - sentryRequest.setCookies(toString(filteredHeaders)); + final @NotNull String cookieName = CookieUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + CookieUtils.filterCookiesFromHeader( + httpRequest.getHeaders(cookieName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + additionalSecurityCookieNames))); + } else if (scopes.getOptions().isSendDefaultPii()) { + sentryRequest.setCookies( + toString( + CookieUtils.filterOutSecurityCookiesFromHeader( + httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames))); } return sentryRequest; } @@ -60,15 +72,19 @@ Map resolveHeadersMap( final @NotNull List additionalSecurityCookieNames) { final Map headersMap = new HashMap<>(); for (String headerName : Collections.list(request.getHeaderNames())) { - // do not copy personal information identifiable headers - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( request.getHeaders(headerName), headerName, additionalSecurityCookieNames); headersMap.put(headerName, toString(filteredHeaders)); } } + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, scopes.getOptions().getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java index c51a2053b8d..c549223e559 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java @@ -110,7 +110,7 @@ private void configureScope( private @NotNull HttpServletRequest resolveHttpServletRequest( final @NotNull IScopes scopes, final @NotNull HttpServletRequest request) { - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isIncomingRequestBody() && qualifiesForCaching(request, scopes.getOptions().getMaxRequestBodySize())) { return new ContentCachingRequestWrapper(request); } @@ -155,7 +155,7 @@ public RequestBodyExtractingEventProcessor( @Override public @NotNull SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { if (event.getRequest() != null - && options.isSendDefaultPii() + && options.getDataCollectionResolver().isIncomingRequestBody() && qualifiesForCaching(request, options.getMaxRequestBodySize())) { event.getRequest().setData(requestPayloadExtractor.extract(request, options)); } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java index 31cc73a3468..23a77f79f0d 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java @@ -46,7 +46,7 @@ protected void doFilterInternal( for (final SentryUserProvider provider : sentryUserProviders) { apply(user, provider.provideUser()); } - if (scopes.getOptions().isSendDefaultPii()) { + if (scopes.getOptions().getDataCollectionResolver().isUserInfo()) { if (IpAddressUtils.isDefault(user.getIpAddress())) { // unset {{auto}} as it would set the server's ip address as a user ip address user.setIpAddress(null); diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SpringSecuritySentryUserProvider.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SpringSecuritySentryUserProvider.java index d36bc4bf2b0..c3f55166c30 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SpringSecuritySentryUserProvider.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SpringSecuritySentryUserProvider.java @@ -22,7 +22,7 @@ public SpringSecuritySentryUserProvider(final @NotNull SentryOptions options) { @Override public @Nullable User provideUser() { - if (options.isSendDefaultPii()) { + if (options.getDataCollectionResolver().isUserInfo()) { final SecurityContext context = SecurityContextHolder.getContext(); if (context != null && context.getAuthentication() != null) { final User user = new User(); diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptor.java index e305816bb05..0628bc1d30e 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptor.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptor.java @@ -63,7 +63,9 @@ public SentrySpanClientHttpRequestInterceptor( final ISpan span = activeSpan.startChild("http.client", null, spanOptions); final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.getURI().toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse( + request.getURI().toString(), scopes.getOptions().getDataCollectionResolver()); span.setDescription(methodName + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, methodName.toUpperCase(Locale.ROOT)); urlDetails.applyToSpan(span); @@ -135,7 +137,11 @@ private void addBreadcrumb( final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; final Breadcrumb breadcrumb = - Breadcrumb.http(request.getURI().toString(), methodName, responseStatusCode); + Breadcrumb.http( + request.getURI().toString(), + methodName, + responseStatusCode, + scopes.getOptions().getDataCollectionResolver()); breadcrumb.setData("request_body_size", body.length); final Hint hint = new Hint(); diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java index ec29b9c68a3..1920a8f6ea2 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java @@ -46,10 +46,14 @@ public SentrySpanClientWebRequestFilter(final @NotNull IScopes scopes) { final @NotNull SpanOptions spanOptions = new SpanOptions(); spanOptions.setOrigin(TRACE_ORIGIN); final ISpan span = activeSpan.startChild("http.client", null, spanOptions); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.url().toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(request.url().toString(), scopes.getOptions().getDataCollectionResolver()); final @NotNull String method = request.method().name(); span.setDescription(method + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)); + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + urlDetails.applyToSpan(span); + } final @NotNull ClientRequest modifiedRequest = maybeAddTracingHeaders(request, span); @@ -115,7 +119,8 @@ private void addBreadcrumb( Breadcrumb.http( request.url().toString(), request.method().name(), - response != null ? response.statusCode().value() : null); + response != null ? response.statusCode().value() : null, + scopes.getOptions().getDataCollectionResolver()); final Hint hint = new Hint(); hint.set(SPRING_EXCHANGE_FILTER_REQUEST, request); diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/AbstractSentryWebFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/AbstractSentryWebFilter.java index 57b7b86e40f..84af5a708e0 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/AbstractSentryWebFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/AbstractSentryWebFilter.java @@ -96,7 +96,13 @@ protected void doFirst( hint.set(WEBFLUX_FILTER_RESPONSE, response); final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; - requestScopes.addBreadcrumb(Breadcrumb.http(request.getURI().toString(), methodName), hint); + final @NotNull Breadcrumb breadcrumb = + Breadcrumb.http( + request.getURI().toString(), + methodName, + null, + requestScopes.getOptions().getDataCollectionResolver()); + requestScopes.addBreadcrumb(breadcrumb, hint); requestScopes.configureScope( scope -> scope.setRequest(sentryRequestResolver.resolveSentryRequest(request))); } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java index d58291ade6e..774de4d6b31 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java @@ -3,6 +3,7 @@ import com.jakewharton.nopen.annotation.Open; import io.sentry.IScopes; import io.sentry.protocol.Request; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; import io.sentry.util.UrlUtils; @@ -32,15 +33,23 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { httpRequest.getMethod() != null ? httpRequest.getMethod().name() : "unknown"; sentryRequest.setMethod(methodName); final @NotNull URI uri = httpRequest.getURI(); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(uri.toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(uri.toString(), scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); - if (scopes.getOptions().isSendDefaultPii()) { - String headerName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String headerName = CookieUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { sentryRequest.setCookies( toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterCookiesFromHeader( + httpRequest.getHeaders().get(headerName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + Collections.emptyList()))); + } else if (scopes.getOptions().isSendDefaultPii()) { + sentryRequest.setCookies( + toString( + CookieUtils.filterOutSecurityCookiesFromHeader( httpRequest.getHeaders().get(headerName), headerName, Collections.emptyList()))); } return sentryRequest; @@ -50,17 +59,21 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { Map resolveHeadersMap(final HttpHeaders request) { final Map headersMap = new HashMap<>(); for (Map.Entry> entry : request.entrySet()) { - // do not copy personal information identifiable headers String headerName = entry.getKey(); - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { headersMap.put( headerName, toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( entry.getValue(), headerName, Collections.emptyList()))); } } + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, scopes.getOptions().getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/HttpServletRequestSentryUserProviderTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/HttpServletRequestSentryUserProviderTest.kt index f2cce25574d..f3cf07525a2 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/HttpServletRequestSentryUserProviderTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/HttpServletRequestSentryUserProviderTest.kt @@ -45,6 +45,43 @@ class HttpServletRequestSentryUserProviderTest { assertEquals("janesmith", result.username) } + @Test + fun `when user info is disabled, does not attach user data`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = + SentryOptions().apply { + isSendDefaultPii = true + dataCollection.setUserInfo(false) + } + val result = HttpServletRequestSentryUserProvider(options).provideUser() + + assertNull(result) + } + + @Test + fun `when user info is enabled, attaches user data`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = + SentryOptions().apply { + isSendDefaultPii = false + dataCollection.setUserInfo(true) + } + val result = HttpServletRequestSentryUserProvider(options).provideUser() + + assertNotNull(result) + assertEquals("janesmith", result.username) + } + @Test fun `when sendDefaultPii is set to false, does not attach user data Sentry Event`() { val principal = mock() diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt index 349839b5d15..f3c94cd4500 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt @@ -1,9 +1,11 @@ package io.sentry.spring.jakarta import io.sentry.Breadcrumb +import io.sentry.HttpBodyType import io.sentry.IScope import io.sentry.IScopes import io.sentry.ISentryLifecycleToken +import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions @@ -203,6 +205,30 @@ class SentrySpringFilterTest { assertNotNull(fixture.scope.request) { assertNull(it.cookies) } } + @Test + fun `data collection filters request headers`() { + val sentryOptions = + SentryOptions().apply { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("customer") + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header("content-type", "application/json") + .header("authorization", "Bearer token") + .header("x-customer", "customer value") + .buildRequest(MockServletContext()), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertEquals("application/json", fixture.scope.request!!.headers!!["Content-Type"]) + assertEquals("[Filtered]", fixture.scope.request!!.headers!!["authorization"]) + assertEquals("[Filtered]", fixture.scope.request!!.headers!!["x-customer"]) + } + @Test fun `when sendDefaultPii is set to false, does not attach sensitive headers`() { val sentryOptions = SentryOptions().apply { isSendDefaultPii = false } @@ -318,6 +344,52 @@ class SentrySpringFilterTest { } } + @Test + fun `data collection can enable request body when sendDefaultPii is false`() { + val options = + SentryOptions().apply { + isSendDefaultPii = false + maxRequestBodySize = SMALL + dataCollection.httpBodies = setOf(HttpBodyType.INCOMING_REQUEST) + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.post(URI.create("http://example.com")) + .content("xxx") + .contentType("application/json") + .buildRequest(MockServletContext()), + options = options, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(check { assertTrue(it is ContentCachingRequestWrapper) }, any()) + } + + @Test + fun `data collection can disable request body when sendDefaultPii is true`() { + val options = + SentryOptions().apply { + isSendDefaultPii = true + maxRequestBodySize = SMALL + dataCollection.httpBodies = emptySet() + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.post(URI.create("http://example.com")) + .content("xxx") + .contentType("application/json") + .buildRequest(MockServletContext()), + options = options, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(check { assertFalse(it is ContentCachingRequestWrapper) }, any()) + } + private fun servletContextWithCustomCookieName(name: String): ServletContext = MockServletContext().also { it.sessionCookieConfig.name = name } } diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryUserFilterTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryUserFilterTest.kt index c790f3e9997..15a7bf377cd 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryUserFilterTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryUserFilterTest.kt @@ -23,9 +23,14 @@ class SentryUserFilterTest { fun getSut( isSendDefaultPii: Boolean = false, + userInfo: Boolean? = null, userProviders: List, ): SentryUserFilter { - val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + val options = + SentryOptions().apply { + this.isSendDefaultPii = isSendDefaultPii + userInfo?.let { dataCollection.setUserInfo(it) } + } whenever(scopes.options).thenReturn(options) return SentryUserFilter(scopes, userProviders) } @@ -118,6 +123,34 @@ class SentryUserFilterTest { verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) } + @Test + fun `when user info is disabled, preserves auto ip from a custom provider`() { + val filter = + fixture.getSut( + isSendDefaultPii = true, + userInfo = false, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "{{auto}}" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertEquals("{{auto}}", it.ipAddress) }) + } + + @Test + fun `when user info is enabled, removes auto ip from a custom provider`() { + val filter = + fixture.getSut( + isSendDefaultPii = false, + userInfo = true, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "{{auto}}" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) + } + private fun assertEquals(user1: User, user2: User) { assertEquals(user1.username, user2.username) assertEquals(user1.id, user2.id) diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SpringSecuritySentryUserProviderTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SpringSecuritySentryUserProviderTest.kt index 80f8efc9ce2..8bd503c3180 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SpringSecuritySentryUserProviderTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SpringSecuritySentryUserProviderTest.kt @@ -16,8 +16,13 @@ class SpringSecuritySentryUserProviderTest { fun getSut( isSendDefaultPii: Boolean = true, username: String? = null, + userInfo: Boolean? = null, ): SpringSecuritySentryUserProvider { - val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + val options = + SentryOptions().apply { + this.isSendDefaultPii = isSendDefaultPii + userInfo?.let { dataCollection.setUserInfo(it) } + } val securityContext = mock() if (username != null) { val authentication = mock() @@ -47,6 +52,20 @@ class SpringSecuritySentryUserProviderTest { assertNull(user) } + @Test + fun `when user info is disabled, returns null even if sendDefaultPii is true`() { + val provider = fixture.getSut(isSendDefaultPii = true, username = "name", userInfo = false) + + assertNull(provider.provideUser()) + } + + @Test + fun `when user info is enabled, returns user even if sendDefaultPii is false`() { + val provider = fixture.getSut(isSendDefaultPii = false, username = "name", userInfo = true) + + assertNotNull(provider.provideUser()) { assertEquals("name", it.username) } + } + @Test fun `when send default pii is set to true and security context is not set, returns null`() { val provider = fixture.getSut(true) diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebFluxTracingFilterTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebFluxTracingFilterTest.kt index f0b8d62e025..0a01b4cbcc4 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebFluxTracingFilterTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebFluxTracingFilterTest.kt @@ -270,7 +270,7 @@ class SentryWebFluxTracingFilterTest { verify(fixture.chain).filter(fixture.exchange) verify(fixture.scopes, times(2)).isEnabled - verify(fixture.scopes, times(4)).options + verify(fixture.scopes, times(5)).options verify(fixture.scopes).continueTrace(anyOrNull(), anyOrNull()) verify(fixture.scopes).addBreadcrumb(any(), any()) verify(fixture.scopes).configureScope(any()) diff --git a/sentry-spring/src/main/java/io/sentry/spring/HttpServletRequestSentryUserProvider.java b/sentry-spring/src/main/java/io/sentry/spring/HttpServletRequestSentryUserProvider.java index c24d2c2ff10..9951e6961dd 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/HttpServletRequestSentryUserProvider.java +++ b/sentry-spring/src/main/java/io/sentry/spring/HttpServletRequestSentryUserProvider.java @@ -23,7 +23,7 @@ public HttpServletRequestSentryUserProvider(final @NotNull SentryOptions options @Override public @Nullable User provideUser() { - if (options.isSendDefaultPii()) { + if (options.getDataCollectionResolver().isUserInfo()) { final RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); if (requestAttributes instanceof ServletRequestAttributes) { final ServletRequestAttributes servletRequestAttributes = diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java index 56294fda083..607fb2b58be 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java @@ -6,6 +6,7 @@ import io.sentry.SentryLevel; import io.sentry.protocol.Request; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; import io.sentry.util.UrlUtils; @@ -37,19 +38,30 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { final Request sentryRequest = new Request(); sentryRequest.setMethod(httpRequest.getMethod()); final @NotNull UrlUtils.UrlDetails urlDetails = - UrlUtils.parse(httpRequest.getRequestURL().toString()); + UrlUtils.parse( + httpRequest.getRequestURL().toString(), + scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); - sentryRequest.setQueryString(httpRequest.getQueryString()); + sentryRequest.setQueryString( + UrlUtils.filterQueryParams( + httpRequest.getQueryString(), scopes.getOptions().getDataCollectionResolver())); final @NotNull List additionalSecurityCookieNames = extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); - if (scopes.getOptions().isSendDefaultPii()) { - String cookieName = HttpUtils.COOKIE_HEADER_NAME; - final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( - httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames); - sentryRequest.setCookies(toString(filteredHeaders)); + final @NotNull String cookieName = CookieUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + CookieUtils.filterCookiesFromHeader( + httpRequest.getHeaders(cookieName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + additionalSecurityCookieNames))); + } else if (scopes.getOptions().isSendDefaultPii()) { + sentryRequest.setCookies( + toString( + CookieUtils.filterOutSecurityCookiesFromHeader( + httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames))); } return sentryRequest; } @@ -60,15 +72,19 @@ Map resolveHeadersMap( final @NotNull List additionalSecurityCookieNames) { final Map headersMap = new HashMap<>(); for (String headerName : Collections.list(request.getHeaderNames())) { - // do not copy personal information identifiable headers - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( request.getHeaders(headerName), headerName, additionalSecurityCookieNames); headersMap.put(headerName, toString(filteredHeaders)); } } + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, scopes.getOptions().getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java b/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java index 69438c82617..3fe8ab9e13f 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java @@ -110,7 +110,7 @@ private void configureScope( private @NotNull HttpServletRequest resolveHttpServletRequest( final @NotNull IScopes scopes, final @NotNull HttpServletRequest request) { - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isIncomingRequestBody() && qualifiesForCaching(request, scopes.getOptions().getMaxRequestBodySize())) { return new ContentCachingRequestWrapper(request); } @@ -155,7 +155,7 @@ public RequestBodyExtractingEventProcessor( @Override public @NotNull SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { if (event.getRequest() != null - && options.isSendDefaultPii() + && options.getDataCollectionResolver().isIncomingRequestBody() && qualifiesForCaching(request, options.getMaxRequestBodySize())) { event.getRequest().setData(requestPayloadExtractor.extract(request, options)); } diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java b/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java index e0b4e9c1ba8..18e1c0d2875 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java @@ -46,7 +46,7 @@ protected void doFilterInternal( for (final SentryUserProvider provider : sentryUserProviders) { apply(user, provider.provideUser()); } - if (scopes.getOptions().isSendDefaultPii()) { + if (scopes.getOptions().getDataCollectionResolver().isUserInfo()) { if (IpAddressUtils.isDefault(user.getIpAddress())) { // unset {{auto}} as it would set the server's ip address as a user ip address user.setIpAddress(null); diff --git a/sentry-spring/src/main/java/io/sentry/spring/SpringSecuritySentryUserProvider.java b/sentry-spring/src/main/java/io/sentry/spring/SpringSecuritySentryUserProvider.java index 23b7820ad94..ef361b0b1f6 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SpringSecuritySentryUserProvider.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SpringSecuritySentryUserProvider.java @@ -22,7 +22,7 @@ public SpringSecuritySentryUserProvider(final @NotNull SentryOptions options) { @Override public @Nullable User provideUser() { - if (options.isSendDefaultPii()) { + if (options.getDataCollectionResolver().isUserInfo()) { final SecurityContext context = SecurityContextHolder.getContext(); if (context != null && context.getAuthentication() != null) { final User user = new User(); diff --git a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientHttpRequestInterceptor.java b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientHttpRequestInterceptor.java index ed63c5ea080..3a0bd6fc8cb 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientHttpRequestInterceptor.java +++ b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientHttpRequestInterceptor.java @@ -55,7 +55,9 @@ public SentrySpanClientHttpRequestInterceptor(final @NotNull IScopes scopes) { final ISpan span = activeSpan.startChild("http.client", null, spanOptions); final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.getURI().toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse( + request.getURI().toString(), scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToSpan(span); span.setDescription(methodName + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, methodName.toUpperCase(Locale.ROOT)); @@ -127,7 +129,11 @@ private void addBreadcrumb( final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; final Breadcrumb breadcrumb = - Breadcrumb.http(request.getURI().toString(), methodName, responseStatusCode); + Breadcrumb.http( + request.getURI().toString(), + methodName, + responseStatusCode, + scopes.getOptions().getDataCollectionResolver()); breadcrumb.setData("request_body_size", body.length); final Hint hint = new Hint(); diff --git a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientWebRequestFilter.java b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientWebRequestFilter.java index e9d787a3dec..eda50c41af2 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientWebRequestFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientWebRequestFilter.java @@ -45,7 +45,8 @@ public SentrySpanClientWebRequestFilter(final @NotNull IScopes scopes) { final @NotNull SpanOptions spanOptions = new SpanOptions(); spanOptions.setOrigin(TRACE_ORIGIN); final ISpan span = activeSpan.startChild("http.client", null, spanOptions); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.url().toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(request.url().toString(), scopes.getOptions().getDataCollectionResolver()); final @NotNull String method = request.method().name(); span.setDescription(method + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)); @@ -115,7 +116,8 @@ private void addBreadcrumb( Breadcrumb.http( request.url().toString(), request.method().name(), - response != null ? response.statusCode().value() : null); + response != null ? response.statusCode().value() : null, + scopes.getOptions().getDataCollectionResolver()); final Hint hint = new Hint(); hint.set(SPRING_EXCHANGE_FILTER_REQUEST, request); diff --git a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java index 76e50985e53..76ad3ba1703 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java @@ -3,6 +3,7 @@ import com.jakewharton.nopen.annotation.Open; import io.sentry.IScopes; import io.sentry.protocol.Request; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; import io.sentry.util.UrlUtils; @@ -32,15 +33,23 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { httpRequest.getMethod() != null ? httpRequest.getMethod().name() : "unknown"; sentryRequest.setMethod(methodName); final @NotNull URI uri = httpRequest.getURI(); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(uri.toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(uri.toString(), scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); - if (scopes.getOptions().isSendDefaultPii()) { - String headerName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String headerName = CookieUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { sentryRequest.setCookies( toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterCookiesFromHeader( + httpRequest.getHeaders().get(headerName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + Collections.emptyList()))); + } else if (scopes.getOptions().isSendDefaultPii()) { + sentryRequest.setCookies( + toString( + CookieUtils.filterOutSecurityCookiesFromHeader( httpRequest.getHeaders().get(headerName), headerName, Collections.emptyList()))); } return sentryRequest; @@ -50,17 +59,21 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { Map resolveHeadersMap(final HttpHeaders request) { final Map headersMap = new HashMap<>(); for (Map.Entry> entry : request.entrySet()) { - // do not copy personal information identifiable headers String headerName = entry.getKey(); - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { headersMap.put( headerName, toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( entry.getValue(), headerName, Collections.emptyList()))); } } + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, scopes.getOptions().getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryWebFilter.java b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryWebFilter.java index 03333d95417..30d1152b88a 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryWebFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryWebFilter.java @@ -102,8 +102,13 @@ isTracingEnabled && shouldTraceRequest(requestScopes, request) hint.set(WEBFLUX_FILTER_RESPONSE, response); final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; - requestScopes.addBreadcrumb( - Breadcrumb.http(request.getURI().toString(), methodName), hint); + final @NotNull Breadcrumb breadcrumb = + Breadcrumb.http( + request.getURI().toString(), + methodName, + null, + requestScopes.getOptions().getDataCollectionResolver()); + requestScopes.addBreadcrumb(breadcrumb, hint); requestScopes.configureScope( scope -> scope.setRequest(sentryRequestResolver.resolveSentryRequest(request))); }); diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/HttpServletRequestSentryUserProviderTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/HttpServletRequestSentryUserProviderTest.kt index 46027a1c09f..3f3cc08bdcd 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/HttpServletRequestSentryUserProviderTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/HttpServletRequestSentryUserProviderTest.kt @@ -45,6 +45,43 @@ class HttpServletRequestSentryUserProviderTest { assertEquals("janesmith", result.username) } + @Test + fun `when user info is disabled, does not attach user data`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = + SentryOptions().apply { + isSendDefaultPii = true + dataCollection.setUserInfo(false) + } + val result = HttpServletRequestSentryUserProvider(options).provideUser() + + assertNull(result) + } + + @Test + fun `when user info is enabled, attaches user data`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = + SentryOptions().apply { + isSendDefaultPii = false + dataCollection.setUserInfo(true) + } + val result = HttpServletRequestSentryUserProvider(options).provideUser() + + assertNotNull(result) + assertEquals("janesmith", result.username) + } + @Test fun `when sendDefaultPii is set to false, does not attach user data Sentry Event`() { val principal = mock() diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt index eb145bcd8a1..ea92a49ceb2 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt @@ -1,9 +1,11 @@ package io.sentry.spring import io.sentry.Breadcrumb +import io.sentry.HttpBodyType import io.sentry.IScope import io.sentry.IScopes import io.sentry.ISentryLifecycleToken +import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions @@ -203,6 +205,79 @@ class SentrySpringFilterTest { assertNotNull(fixture.scope.request) { assertNull(it.cookies) } } + @Test + fun `data collection filters cookies and ignores sendDefaultPii`() { + val sentryOptions = + SentryOptions().apply { + isSendDefaultPii = false + dataCollection.cookies = KeyValueCollectionBehavior.denyList("customer") + } + + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header( + "Cookie", + "theme=dark; sessionId=secret; customerId=123; customSession=456", + ) + .buildRequest(servletContextWithCustomCookieName("customSession")), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertEquals( + "theme=dark; sessionId=[Filtered]; customerId=[Filtered]; customSession=[Filtered]", + fixture.scope.request!!.cookies, + ) + } + + @Test + fun `data collection can disable cookies`() { + val sentryOptions = + SentryOptions().apply { + isSendDefaultPii = true + dataCollection.cookies = KeyValueCollectionBehavior.off() + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header("Cookie", "theme=dark") + .buildRequest(MockServletContext()), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertNull(fixture.scope.request!!.cookies) + } + + @Test + fun `data collection filters request headers`() { + val sentryOptions = + SentryOptions().apply { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("customer") + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header("content-type", "application/json") + .header("authorization", "Bearer token") + .header("x-customer", "customer value") + .buildRequest(MockServletContext()), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertEquals("application/json", fixture.scope.request!!.headers!!["Content-Type"]) + assertEquals("[Filtered]", fixture.scope.request!!.headers!!["authorization"]) + assertEquals("[Filtered]", fixture.scope.request!!.headers!!["x-customer"]) + } + @Test fun `when sendDefaultPii is set to false, does not attach sensitive headers`() { val sentryOptions = SentryOptions().apply { isSendDefaultPii = false } @@ -318,6 +393,52 @@ class SentrySpringFilterTest { } } + @Test + fun `data collection can enable request body when sendDefaultPii is false`() { + val options = + SentryOptions().apply { + isSendDefaultPii = false + maxRequestBodySize = SMALL + dataCollection.httpBodies = setOf(HttpBodyType.INCOMING_REQUEST) + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.post(URI.create("http://example.com")) + .content("xxx") + .contentType("application/json") + .buildRequest(MockServletContext()), + options = options, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(check { assertTrue(it is ContentCachingRequestWrapper) }, any()) + } + + @Test + fun `data collection can disable request body when sendDefaultPii is true`() { + val options = + SentryOptions().apply { + isSendDefaultPii = true + maxRequestBodySize = SMALL + dataCollection.httpBodies = emptySet() + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.post(URI.create("http://example.com")) + .content("xxx") + .contentType("application/json") + .buildRequest(MockServletContext()), + options = options, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(check { assertFalse(it is ContentCachingRequestWrapper) }, any()) + } + private fun servletContextWithCustomCookieName(name: String): ServletContext = MockServletContext().also { it.sessionCookieConfig.name = name } } diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserFilterTest.kt index f545e605560..07283bd5b95 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserFilterTest.kt @@ -23,9 +23,14 @@ class SentryUserFilterTest { fun getSut( isSendDefaultPii: Boolean = false, + userInfo: Boolean? = null, userProviders: List, ): SentryUserFilter { - val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + val options = + SentryOptions().apply { + this.isSendDefaultPii = isSendDefaultPii + userInfo?.let { dataCollection.setUserInfo(it) } + } whenever(scopes.options).thenReturn(options) return SentryUserFilter(scopes, userProviders) } @@ -118,6 +123,34 @@ class SentryUserFilterTest { verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) } + @Test + fun `when user info is disabled, preserves auto ip from a custom provider`() { + val filter = + fixture.getSut( + isSendDefaultPii = true, + userInfo = false, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "{{auto}}" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertEquals("{{auto}}", it.ipAddress) }) + } + + @Test + fun `when user info is enabled, removes auto ip from a custom provider`() { + val filter = + fixture.getSut( + isSendDefaultPii = false, + userInfo = true, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "{{auto}}" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) + } + private fun assertEquals(user1: User, user2: User) { assertEquals(user1.username, user2.username) assertEquals(user1.id, user2.id) diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SpringSecuritySentryUserProviderTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SpringSecuritySentryUserProviderTest.kt index 3fa443658d3..7ba3ea787e9 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SpringSecuritySentryUserProviderTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SpringSecuritySentryUserProviderTest.kt @@ -16,8 +16,13 @@ class SpringSecuritySentryUserProviderTest { fun getSut( isSendDefaultPii: Boolean = true, username: String? = null, + userInfo: Boolean? = null, ): SpringSecuritySentryUserProvider { - val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + val options = + SentryOptions().apply { + this.isSendDefaultPii = isSendDefaultPii + userInfo?.let { dataCollection.setUserInfo(it) } + } val securityContext = mock() if (username != null) { val authentication = mock() @@ -47,6 +52,20 @@ class SpringSecuritySentryUserProviderTest { assertNull(user) } + @Test + fun `when user info is disabled, returns null even if sendDefaultPii is true`() { + val provider = fixture.getSut(isSendDefaultPii = true, username = "name", userInfo = false) + + assertNull(provider.provideUser()) + } + + @Test + fun `when user info is enabled, returns user even if sendDefaultPii is false`() { + val provider = fixture.getSut(isSendDefaultPii = false, username = "name", userInfo = true) + + assertNotNull(provider.provideUser()) { assertEquals("name", it.username) } + } + @Test fun `when send default pii is set to true and security context is not set, returns null`() { val provider = fixture.getSut(true) diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebFluxTracingFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebFluxTracingFilterTest.kt index 5d91ec58486..326b5979991 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebFluxTracingFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebFluxTracingFilterTest.kt @@ -271,7 +271,7 @@ class SentryWebFluxTracingFilterTest { verify(fixture.chain).filter(fixture.exchange) verify(fixture.scopes).isEnabled - verify(fixture.scopes, times(4)).options + verify(fixture.scopes, times(5)).options verify(fixture.scopes).continueTrace(anyOrNull(), anyOrNull()) verify(fixture.scopes).addBreadcrumb(any(), any()) verify(fixture.scopes).configureScope(any()) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 9f44481fd96..8b694d7fe17 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -132,6 +132,7 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public fun hashCode ()I public static fun http (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; public static fun http (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)Lio/sentry/Breadcrumb; + public static fun http (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Lio/sentry/DataCollectionResolver;)Lio/sentry/Breadcrumb; public static fun info (Ljava/lang/String;)Lio/sentry/Breadcrumb; public static fun navigation (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; public static fun query (Ljava/lang/String;)Lio/sentry/Breadcrumb; @@ -384,6 +385,61 @@ public final class io/sentry/DataCategory : java/lang/Enum { public static fun values ()[Lio/sentry/DataCategory; } +public final class io/sentry/DataCollection { + public fun ()V + public fun (Z)V + public fun getCookies ()Lio/sentry/KeyValueCollectionBehavior; + public fun getDatabaseQueryData ()Ljava/lang/Boolean; + public fun getGraphql ()Lio/sentry/DataCollection$Graphql; + public fun getHttpBodies ()Ljava/util/Set; + public fun getHttpHeaders ()Lio/sentry/DataCollection$HttpHeaders; + public fun getUrlQueryParams ()Lio/sentry/KeyValueCollectionBehavior; + public fun getUserInfo ()Ljava/lang/Boolean; + public fun isExplicitlyConfigured ()Z + public fun setCookies (Lio/sentry/KeyValueCollectionBehavior;)V + public fun setDatabaseQueryData (Z)V + public fun setHttpBodies (Ljava/util/Set;)V + public fun setUrlQueryParams (Lio/sentry/KeyValueCollectionBehavior;)V + public fun setUserInfo (Z)V +} + +public final class io/sentry/DataCollection$Graphql { + public fun ()V + public fun getDocument ()Ljava/lang/Boolean; + public fun getVariables ()Ljava/lang/Boolean; + public fun setDocument (Z)V + public fun setVariables (Z)V +} + +public final class io/sentry/DataCollection$HttpHeaders { + public fun ()V + public fun getRequest ()Lio/sentry/KeyValueCollectionBehavior; + public fun getResponse ()Lio/sentry/KeyValueCollectionBehavior; + public fun setRequest (Lio/sentry/KeyValueCollectionBehavior;)V + public fun setResponse (Lio/sentry/KeyValueCollectionBehavior;)V +} + +public final class io/sentry/DataCollectionResolver { + public fun getCookies ()Lio/sentry/KeyValueCollectionBehavior; + public fun getHttpRequestHeaders ()Lio/sentry/KeyValueCollectionBehavior; + public fun getHttpResponseHeaders ()Lio/sentry/KeyValueCollectionBehavior; + public fun getUrlQueryParams ()Lio/sentry/KeyValueCollectionBehavior; + public fun isDataCollectionConfigured ()Z + public fun isDatabaseQueryData ()Z + public fun isGraphqlDocument ()Z + public fun isGraphqlDocumentWithLegacyAlways ()Z + public fun isGraphqlDocumentWithLegacyBodyGate ()Z + public fun isGraphqlVariables ()Z + public fun isGraphqlVariablesWithLegacyAlways ()Z + public fun isGraphqlVariablesWithLegacyBodyGate ()Z + public fun isIncomingRequestBody ()Z + public fun isIncomingResponseBody ()Z + public fun isOutgoingRequestBody ()Z + public fun isOutgoingResponseBody ()Z + public fun isOutgoingResponseBodyWithLegacyBodyGate ()Z + public fun isUserInfo ()Z +} + public final class io/sentry/DateUtils { public static fun dateToSeconds (Ljava/util/Date;)D public static fun doubleToBigDecimal (D)Ljava/math/BigDecimal; @@ -494,6 +550,7 @@ public final class io/sentry/ExternalOptions { public fun getBundleIds ()Ljava/util/Set; public fun getContextTags ()Ljava/util/List; public fun getCron ()Lio/sentry/SentryOptions$Cron; + public fun getDataCollection ()Lio/sentry/DataCollection; public fun getDebug ()Ljava/lang/Boolean; public fun getDist ()Ljava/lang/String; public fun getDsn ()Ljava/lang/String; @@ -543,6 +600,7 @@ public final class io/sentry/ExternalOptions { public fun isStrictTraceContinuation ()Ljava/lang/Boolean; public fun setCaptureOpenTelemetryEvents (Ljava/lang/Boolean;)V public fun setCron (Lio/sentry/SentryOptions$Cron;)V + public fun setDataCollection (Lio/sentry/DataCollection;)V public fun setDebug (Ljava/lang/Boolean;)V public fun setDist (Ljava/lang/String;)V public fun setDsn (Ljava/lang/String;)V @@ -636,6 +694,15 @@ public final class io/sentry/HostnameCache { public static fun getInstance ()Lio/sentry/HostnameCache; } +public final class io/sentry/HttpBodyType : java/lang/Enum { + public static final field INCOMING_REQUEST Lio/sentry/HttpBodyType; + public static final field INCOMING_RESPONSE Lio/sentry/HttpBodyType; + public static final field OUTGOING_REQUEST Lio/sentry/HttpBodyType; + public static final field OUTGOING_RESPONSE Lio/sentry/HttpBodyType; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/HttpBodyType; + public static fun values ()[Lio/sentry/HttpBodyType; +} + public final class io/sentry/HttpStatusCodeRange { public static final field DEFAULT_MAX I public static final field DEFAULT_MIN I @@ -1392,6 +1459,27 @@ public abstract interface class io/sentry/JsonUnknown { public abstract fun setUnknown (Ljava/util/Map;)V } +public final class io/sentry/KeyValueCollectionBehavior { + public fun ()V + public static fun allowList ([Ljava/lang/String;)Lio/sentry/KeyValueCollectionBehavior; + public static fun denyList ([Ljava/lang/String;)Lio/sentry/KeyValueCollectionBehavior; + public fun equals (Ljava/lang/Object;)Z + public fun getMode ()Lio/sentry/KeyValueCollectionBehavior$Mode; + public fun getTerms ()Ljava/util/List; + public fun hashCode ()I + public static fun off ()Lio/sentry/KeyValueCollectionBehavior; + public fun setMode (Lio/sentry/KeyValueCollectionBehavior$Mode;)V + public fun setTerms (Ljava/util/List;)V +} + +public final class io/sentry/KeyValueCollectionBehavior$Mode : java/lang/Enum { + public static final field ALLOW_LIST Lio/sentry/KeyValueCollectionBehavior$Mode; + public static final field DENY_LIST Lio/sentry/KeyValueCollectionBehavior$Mode; + public static final field OFF Lio/sentry/KeyValueCollectionBehavior$Mode; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/KeyValueCollectionBehavior$Mode; + public static fun values ()[Lio/sentry/KeyValueCollectionBehavior$Mode; +} + public final class io/sentry/MainEventProcessor : io/sentry/EventProcessor, java/io/Closeable { public fun (Lio/sentry/SentryOptions;)V public fun close ()V @@ -3693,6 +3781,8 @@ public class io/sentry/SentryOptions { public fun getContextTags ()Ljava/util/List; public fun getContinuousProfiler ()Lio/sentry/IContinuousProfiler; public fun getCron ()Lio/sentry/SentryOptions$Cron; + public fun getDataCollection ()Lio/sentry/DataCollection; + public fun getDataCollectionResolver ()Lio/sentry/DataCollectionResolver; public fun getDateProvider ()Lio/sentry/SentryDateProvider; public fun getDeadlineTimeout ()J public fun getDebugMetaLoader ()Lio/sentry/internal/debugmeta/IDebugMetaLoader; @@ -3843,6 +3933,7 @@ public class io/sentry/SentryOptions { public fun setConnectionTimeoutMillis (I)V public fun setContinuousProfiler (Lio/sentry/IContinuousProfiler;)V public fun setCron (Lio/sentry/SentryOptions$Cron;)V + public fun setDataCollection (Lio/sentry/DataCollection;)V public fun setDateProvider (Lio/sentry/SentryDateProvider;)V public fun setDeadlineTimeout (J)V public fun setDebug (Z)V @@ -7737,6 +7828,21 @@ public abstract interface class io/sentry/util/CollectionUtils$Predicate { public abstract fun test (Ljava/lang/Object;)Z } +public final class io/sentry/util/CookieUtils { + public static final field COOKIE_HEADER_NAME Ljava/lang/String; + public fun ()V + public static fun filterCookies (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/lang/String; + public static fun filterCookies (Ljava/lang/String;Lio/sentry/SentryOptions;)Ljava/lang/String; + public static fun filterCookiesFromHeader (Ljava/util/Enumeration;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/util/List; + public static fun filterCookiesFromHeader (Ljava/util/List;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/util/List; + public static fun filterOutSecurityCookies (Ljava/lang/String;Ljava/util/List;)Ljava/lang/String; + public static fun filterOutSecurityCookiesFromHeader (Ljava/util/Enumeration;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; + public static fun filterOutSecurityCookiesFromHeader (Ljava/util/List;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; + public static fun filterSetCookie (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;)Ljava/lang/String; + public static fun filterSetCookie (Ljava/lang/String;Lio/sentry/SentryOptions;)Ljava/lang/String; + public static fun isSecurityCookie (Ljava/lang/String;Ljava/util/List;)Z +} + public final class io/sentry/util/DebugMetaPropertiesApplier { public static field DEBUG_META_PROPERTIES_FILENAME Ljava/lang/String; public fun ()V @@ -7777,6 +7883,10 @@ public final class io/sentry/util/FileUtils { public static fun readText (Ljava/io/File;)Ljava/lang/String; } +public final class io/sentry/util/GraphqlUtils { + public static fun filterRequestBody (Ljava/lang/String;Lio/sentry/SentryOptions;)Ljava/lang/String; +} + public final class io/sentry/util/HintUtils { public static fun createWithTypeCheckHint (Ljava/lang/Object;)Lio/sentry/Hint; public static fun getEventDropReason (Lio/sentry/Hint;)Lio/sentry/hints/EventDropReason; @@ -7808,15 +7918,12 @@ public abstract interface class io/sentry/util/HintUtils$SentryNullableConsumer } public final class io/sentry/util/HttpUtils { - public static final field COOKIE_HEADER_NAME Ljava/lang/String; public fun ()V public static fun containsSensitiveHeader (Ljava/lang/String;)Z - public static fun filterOutSecurityCookies (Ljava/lang/String;Ljava/util/List;)Ljava/lang/String; - public static fun filterOutSecurityCookiesFromHeader (Ljava/util/Enumeration;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; - public static fun filterOutSecurityCookiesFromHeader (Ljava/util/List;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; + public static fun filterHeaders (Ljava/util/Map;Lio/sentry/KeyValueCollectionBehavior;)Ljava/util/Map; + public static fun filterQueryParams (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;)Ljava/lang/String; public static fun isHttpClientError (I)Z public static fun isHttpServerError (I)Z - public static fun isSecurityCookie (Ljava/lang/String;Ljava/util/List;)Z } public final class io/sentry/util/InitUtil { @@ -8083,7 +8190,9 @@ public final class io/sentry/util/UUIDStringUtils { public final class io/sentry/util/UrlUtils { public static final field SENSITIVE_DATA_SUBSTITUTE Ljava/lang/String; public fun ()V + public static fun filterQueryParams (Ljava/lang/String;Lio/sentry/DataCollectionResolver;)Ljava/lang/String; public static fun parse (Ljava/lang/String;)Lio/sentry/util/UrlUtils$UrlDetails; + public static fun parse (Ljava/lang/String;Lio/sentry/DataCollectionResolver;)Lio/sentry/util/UrlUtils$UrlDetails; public static fun parseNullable (Ljava/lang/String;)Lio/sentry/util/UrlUtils$UrlDetails; } diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index fff6954ee56..b04bddb159a 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -192,8 +192,15 @@ public static Breadcrumb fromMap( * @return the breadcrumb */ public static @NotNull Breadcrumb http(final @NotNull String url, final @NotNull String method) { + return createHttpBreadcrumb(url, method, null); + } + + private static @NotNull Breadcrumb createHttpBreadcrumb( + final @NotNull String url, + final @NotNull String method, + final @Nullable DataCollectionResolver resolver) { final Breadcrumb breadcrumb = new Breadcrumb(); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(url); + final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(url, resolver); breadcrumb.setType("http"); breadcrumb.setCategory("http"); if (urlDetails.getUrl() != null) { @@ -220,7 +227,21 @@ public static Breadcrumb fromMap( */ public static @NotNull Breadcrumb http( final @NotNull String url, final @NotNull String method, final @Nullable Integer code) { - final Breadcrumb breadcrumb = http(url, method); + final Breadcrumb breadcrumb = createHttpBreadcrumb(url, method, null); + if (code != null) { + breadcrumb.setData("status_code", code); + breadcrumb.setLevel(levelFromHttpStatusCode(code)); + } + return breadcrumb; + } + + @ApiStatus.Internal + public static @NotNull Breadcrumb http( + final @NotNull String url, + final @NotNull String method, + final @Nullable Integer code, + final @Nullable DataCollectionResolver resolver) { + final Breadcrumb breadcrumb = createHttpBreadcrumb(url, method, resolver); if (code != null) { breadcrumb.setData("status_code", code); breadcrumb.setLevel(levelFromHttpStatusCode(code)); diff --git a/sentry/src/main/java/io/sentry/DataCollection.java b/sentry/src/main/java/io/sentry/DataCollection.java new file mode 100644 index 00000000000..6b0ec5acd7f --- /dev/null +++ b/sentry/src/main/java/io/sentry/DataCollection.java @@ -0,0 +1,148 @@ +package io.sentry; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Configures data that the SDK collects automatically. */ +public final class DataCollection { + + // Forces Data Collection to be used even when no individual option has been configured. + private boolean forceDataCollection; + private @Nullable Boolean userInfo; + private @Nullable KeyValueCollectionBehavior cookies; + private @Nullable KeyValueCollectionBehavior urlQueryParams; + private @Nullable Set httpBodies; + private @Nullable Boolean databaseQueryData; + private final @NotNull HttpHeaders httpHeaders = new HttpHeaders(); + private final @NotNull Graphql graphql = new Graphql(); + + public DataCollection() { + this(true); + } + + @ApiStatus.Internal + public DataCollection(final boolean forceDataCollection) { + this.forceDataCollection = forceDataCollection; + } + + public @Nullable Boolean getUserInfo() { + return userInfo; + } + + public void setUserInfo(final boolean userInfo) { + this.userInfo = userInfo; + } + + public @Nullable KeyValueCollectionBehavior getCookies() { + return cookies; + } + + public void setCookies(final @Nullable KeyValueCollectionBehavior cookies) { + this.cookies = cookies; + } + + public @Nullable KeyValueCollectionBehavior getUrlQueryParams() { + return urlQueryParams; + } + + public void setUrlQueryParams(final @Nullable KeyValueCollectionBehavior urlQueryParams) { + this.urlQueryParams = urlQueryParams; + } + + public @Nullable Set getHttpBodies() { + return httpBodies; + } + + public void setHttpBodies(final @Nullable Set httpBodies) { + this.httpBodies = + httpBodies == null + ? null + : httpBodies.isEmpty() + ? Collections.emptySet() + : Collections.unmodifiableSet(EnumSet.copyOf(httpBodies)); + } + + public @Nullable Boolean getDatabaseQueryData() { + return databaseQueryData; + } + + public void setDatabaseQueryData(final boolean databaseQueryData) { + this.databaseQueryData = databaseQueryData; + } + + public @NotNull HttpHeaders getHttpHeaders() { + return httpHeaders; + } + + public @NotNull Graphql getGraphql() { + return graphql; + } + + @ApiStatus.Internal + public boolean isExplicitlyConfigured() { + return forceDataCollection + || userInfo != null + || cookies != null + || urlQueryParams != null + || httpBodies != null + || databaseQueryData != null + || httpHeaders.hasOverrides() + || graphql.hasOverrides(); + } + + /** Configures collection of request and response HTTP headers. */ + public static final class HttpHeaders { + private @Nullable KeyValueCollectionBehavior request; + private @Nullable KeyValueCollectionBehavior response; + + public @Nullable KeyValueCollectionBehavior getRequest() { + return request; + } + + public void setRequest(final @Nullable KeyValueCollectionBehavior request) { + this.request = request; + } + + public @Nullable KeyValueCollectionBehavior getResponse() { + return response; + } + + public void setResponse(final @Nullable KeyValueCollectionBehavior response) { + this.response = response; + } + + private boolean hasOverrides() { + return request != null || response != null; + } + } + + /** Configures collection of GraphQL document and variable content. */ + public static final class Graphql { + private @Nullable Boolean document; + private @Nullable Boolean variables; + + public @Nullable Boolean getDocument() { + return document; + } + + public void setDocument(final boolean document) { + this.document = document; + } + + public @Nullable Boolean getVariables() { + return variables; + } + + public void setVariables(final boolean variables) { + this.variables = variables; + } + + private boolean hasOverrides() { + return document != null || variables != null; + } + } +} diff --git a/sentry/src/main/java/io/sentry/DataCollectionResolver.java b/sentry/src/main/java/io/sentry/DataCollectionResolver.java new file mode 100644 index 00000000000..531a791262c --- /dev/null +++ b/sentry/src/main/java/io/sentry/DataCollectionResolver.java @@ -0,0 +1,137 @@ +package io.sentry; + +import java.util.Set; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Resolves effective Data Collection policies for SDK integrations. */ +@ApiStatus.Internal +public final class DataCollectionResolver { + + private final @NotNull SentryOptions options; + + DataCollectionResolver(final @NotNull SentryOptions options) { + this.options = options; + } + + public boolean isDataCollectionConfigured() { + return options.getDataCollection().isExplicitlyConfigured(); + } + + public boolean isUserInfo() { + return explicitOrSendDefaultPii(options.getDataCollection().getUserInfo(), true); + } + + public boolean isDatabaseQueryData() { + return explicitOrSendDefaultPii(options.getDataCollection().getDatabaseQueryData(), true); + } + + public boolean isGraphqlDocument() { + return explicitOrSendDefaultPii(options.getDataCollection().getGraphql().getDocument(), true); + } + + public boolean isGraphqlDocumentWithLegacyBodyGate() { + return explicitOrDefault( + options.getDataCollection().getGraphql().getDocument(), true, isLegacyGraphqlBodyEnabled()); + } + + public boolean isGraphqlDocumentWithLegacyAlways() { + return explicitOrDefault(options.getDataCollection().getGraphql().getDocument(), true, true); + } + + public boolean isGraphqlVariables() { + return explicitOrSendDefaultPii(options.getDataCollection().getGraphql().getVariables(), true); + } + + public boolean isGraphqlVariablesWithLegacyBodyGate() { + return explicitOrDefault( + options.getDataCollection().getGraphql().getVariables(), + true, + isLegacyGraphqlBodyEnabled()); + } + + public boolean isGraphqlVariablesWithLegacyAlways() { + return explicitOrDefault(options.getDataCollection().getGraphql().getVariables(), true, true); + } + + public @NotNull KeyValueCollectionBehavior getCookies() { + final @NotNull DataCollection dataCollection = options.getDataCollection(); + final @Nullable KeyValueCollectionBehavior cookies = dataCollection.getCookies(); + + if (cookies != null) { + return cookies; + } + if (isDataCollectionConfigured()) { + return KeyValueCollectionBehavior.denyList(); + } + return options.isSendDefaultPii() + ? KeyValueCollectionBehavior.denyList() + : KeyValueCollectionBehavior.off(); + } + + public @NotNull KeyValueCollectionBehavior getUrlQueryParams() { + return explicitOrEmptyDenyList(options.getDataCollection().getUrlQueryParams()); + } + + public @NotNull KeyValueCollectionBehavior getHttpRequestHeaders() { + return explicitOrEmptyDenyList(options.getDataCollection().getHttpHeaders().getRequest()); + } + + public @NotNull KeyValueCollectionBehavior getHttpResponseHeaders() { + return explicitOrEmptyDenyList(options.getDataCollection().getHttpHeaders().getResponse()); + } + + public boolean isIncomingRequestBody() { + return isHttpBodyEnabled(HttpBodyType.INCOMING_REQUEST, options.isSendDefaultPii()); + } + + public boolean isOutgoingRequestBody() { + return isHttpBodyEnabled(HttpBodyType.OUTGOING_REQUEST, true); + } + + public boolean isIncomingResponseBody() { + return isHttpBodyEnabled(HttpBodyType.INCOMING_RESPONSE, true); + } + + public boolean isOutgoingResponseBody() { + return isHttpBodyEnabled(HttpBodyType.OUTGOING_RESPONSE, options.isSendDefaultPii()); + } + + public boolean isOutgoingResponseBodyWithLegacyBodyGate() { + return isHttpBodyEnabled(HttpBodyType.OUTGOING_RESPONSE, isLegacyGraphqlBodyEnabled()); + } + + private boolean isLegacyGraphqlBodyEnabled() { + return options.isSendDefaultPii() + && !SentryOptions.RequestSize.NONE.equals(options.getMaxRequestBodySize()); + } + + private boolean explicitOrSendDefaultPii( + final @Nullable Boolean explicit, final boolean defaultValue) { + return explicitOrDefault(explicit, defaultValue, options.isSendDefaultPii()); + } + + private boolean explicitOrDefault( + final @Nullable Boolean explicit, final boolean defaultValue, final boolean legacyFallback) { + if (explicit != null) { + return explicit; + } + return isDataCollectionConfigured() ? defaultValue : legacyFallback; + } + + private @NotNull KeyValueCollectionBehavior explicitOrEmptyDenyList( + final @Nullable KeyValueCollectionBehavior explicit) { + return explicit != null ? explicit : KeyValueCollectionBehavior.denyList(); + } + + private boolean isHttpBodyEnabled( + final @NotNull HttpBodyType bodyType, final boolean legacyFallback) { + final @NotNull DataCollection dataCollection = options.getDataCollection(); + final @Nullable Set httpBodies = dataCollection.getHttpBodies(); + if (httpBodies != null) { + return httpBodies.contains(bodyType); + } + return isDataCollectionConfigured() || legacyFallback; + } +} diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 4e44ea422ec..abcb229e7e5 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -55,6 +55,7 @@ public final class ExternalOptions { private @Nullable Boolean sendModules; private @Nullable Boolean sendDefaultPii; + private @Nullable DataCollection dataCollection; private @Nullable Boolean enableBackpressureHandling; private @Nullable Boolean enableDatabaseTransactionTracing; private @Nullable Boolean enableCacheTracing; @@ -157,6 +158,7 @@ public final class ExternalOptions { options.setSendModules(propertiesProvider.getBooleanProperty("send-modules")); options.setSendDefaultPii(propertiesProvider.getBooleanProperty("send-default-pii")); + options.setDataCollection(parseDataCollection(propertiesProvider)); options.setIgnoredCheckIns(propertiesProvider.getListOrNull("ignored-checkins")); options.setIgnoredTransactions(propertiesProvider.getListOrNull("ignored-transactions")); @@ -246,6 +248,101 @@ public final class ExternalOptions { return options; } + private static @Nullable DataCollection parseDataCollection( + final @NotNull PropertiesProvider propertiesProvider) { + final DataCollection dataCollection = new DataCollection(false); + + final Boolean userInfo = propertiesProvider.getBooleanProperty("data-collection.user-info"); + if (userInfo != null) { + dataCollection.setUserInfo(userInfo); + } + + final Set httpBodies = parseHttpBodies(propertiesProvider); + if (httpBodies != null) { + dataCollection.setHttpBodies(httpBodies); + } + + final KeyValueCollectionBehavior cookies = + parseKeyValueCollectionBehavior(propertiesProvider, "data-collection.cookies"); + if (cookies != null) { + dataCollection.setCookies(cookies); + } + + final KeyValueCollectionBehavior requestHeaders = + parseKeyValueCollectionBehavior(propertiesProvider, "data-collection.http-headers.request"); + if (requestHeaders != null) { + dataCollection.getHttpHeaders().setRequest(requestHeaders); + } + + final KeyValueCollectionBehavior responseHeaders = + parseKeyValueCollectionBehavior( + propertiesProvider, "data-collection.http-headers.response"); + if (responseHeaders != null) { + dataCollection.getHttpHeaders().setResponse(responseHeaders); + } + + final KeyValueCollectionBehavior queryParams = + parseKeyValueCollectionBehavior(propertiesProvider, "data-collection.url-query-params"); + if (queryParams != null) { + dataCollection.setUrlQueryParams(queryParams); + } + + final Boolean graphqlDocument = + propertiesProvider.getBooleanProperty("data-collection.graphql.document"); + if (graphqlDocument != null) { + dataCollection.getGraphql().setDocument(graphqlDocument); + } + + final Boolean graphqlVariables = + propertiesProvider.getBooleanProperty("data-collection.graphql.variables"); + if (graphqlVariables != null) { + dataCollection.getGraphql().setVariables(graphqlVariables); + } + + final Boolean databaseQueryData = + propertiesProvider.getBooleanProperty("data-collection.database-query-data"); + if (databaseQueryData != null) { + dataCollection.setDatabaseQueryData(databaseQueryData); + } + + return dataCollection.isExplicitlyConfigured() ? dataCollection : null; + } + + private static @Nullable Set parseHttpBodies( + final @NotNull PropertiesProvider propertiesProvider) { + final List bodyTypes = propertiesProvider.getListOrNull("data-collection.http-bodies"); + if (bodyTypes == null) { + return null; + } + if (bodyTypes.size() == 1 && bodyTypes.get(0).isEmpty()) { + return Collections.emptySet(); + } + + final Set httpBodies = EnumSet.noneOf(HttpBodyType.class); + for (final String bodyType : bodyTypes) { + httpBodies.add(HttpBodyType.valueOf(bodyType.toUpperCase(Locale.ROOT))); + } + return httpBodies; + } + + private static @Nullable KeyValueCollectionBehavior parseKeyValueCollectionBehavior( + final @NotNull PropertiesProvider propertiesProvider, final @NotNull String property) { + final String modeValue = propertiesProvider.getProperty(property + ".mode"); + final List terms = propertiesProvider.getListOrNull(property + ".terms"); + if (modeValue == null && terms == null) { + return null; + } + + final KeyValueCollectionBehavior behavior = new KeyValueCollectionBehavior(); + if (modeValue != null) { + behavior.setMode(KeyValueCollectionBehavior.Mode.valueOf(modeValue.toUpperCase(Locale.ROOT))); + } + if (terms != null) { + behavior.setTerms(terms); + } + return behavior; + } + public @Nullable String getDsn() { return dsn; } @@ -501,6 +598,14 @@ public void setSendDefaultPii(final @Nullable Boolean sendDefaultPii) { this.sendDefaultPii = sendDefaultPii; } + public @Nullable DataCollection getDataCollection() { + return dataCollection; + } + + public void setDataCollection(final @Nullable DataCollection dataCollection) { + this.dataCollection = dataCollection; + } + public void setIgnoredCheckIns(final @Nullable List ignoredCheckIns) { this.ignoredCheckIns = ignoredCheckIns; } diff --git a/sentry/src/main/java/io/sentry/HttpBodyType.java b/sentry/src/main/java/io/sentry/HttpBodyType.java new file mode 100644 index 00000000000..9b1b9a24b50 --- /dev/null +++ b/sentry/src/main/java/io/sentry/HttpBodyType.java @@ -0,0 +1,13 @@ +package io.sentry; + +/** A direction of automatically collected HTTP body content. */ +public enum HttpBodyType { + /** A request received by a server integration. */ + INCOMING_REQUEST, + /** A request sent by a client integration. */ + OUTGOING_REQUEST, + /** A response received by a client integration. */ + INCOMING_RESPONSE, + /** A response sent by a server integration. */ + OUTGOING_RESPONSE +} diff --git a/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java b/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java new file mode 100644 index 00000000000..35cc0896719 --- /dev/null +++ b/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java @@ -0,0 +1,87 @@ +package io.sentry; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +/** Controls how automatically collected key-value data is filtered. */ +public final class KeyValueCollectionBehavior { + + /** The collection strategy applied to key-value data. */ + public enum Mode { + /** Do not collect keys or values. */ + OFF, + /** Collect keys and filter values whose keys match a deny-list term. */ + DENY_LIST, + /** Collect keys and filter values unless their keys match an allow-list term. */ + ALLOW_LIST + } + + private @NotNull Mode mode = Mode.DENY_LIST; + private @NotNull List terms = Collections.emptyList(); + + /** Creates a behavior that collects values using the built-in sensitive deny-list. */ + public KeyValueCollectionBehavior() {} + + private KeyValueCollectionBehavior(final @NotNull Mode mode, final @NotNull List terms) { + setMode(mode); + setTerms(terms); + } + + /** Disables collection of the category. */ + public static @NotNull KeyValueCollectionBehavior off() { + return new KeyValueCollectionBehavior(Mode.OFF, Collections.emptyList()); + } + + /** + * Collects the category and filters values whose keys match the built-in sensitive deny-list or + * one of {@code terms}. + */ + public static @NotNull KeyValueCollectionBehavior denyList(final @NotNull String... terms) { + return new KeyValueCollectionBehavior(Mode.DENY_LIST, Arrays.asList(terms)); + } + + /** + * Collects the category and only includes plaintext values whose keys match one of {@code terms}. + * Values matching the built-in sensitive deny-list are still filtered. + */ + public static @NotNull KeyValueCollectionBehavior allowList(final @NotNull String... terms) { + return new KeyValueCollectionBehavior(Mode.ALLOW_LIST, Arrays.asList(terms)); + } + + public @NotNull Mode getMode() { + return mode; + } + + public void setMode(final @NotNull Mode mode) { + this.mode = mode; + } + + public @NotNull List getTerms() { + return terms; + } + + public void setTerms(final @NotNull List terms) { + this.terms = Collections.unmodifiableList(new ArrayList<>(terms)); + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + final KeyValueCollectionBehavior that = (KeyValueCollectionBehavior) other; + return mode == that.mode && terms.equals(that.terms); + } + + @Override + public int hashCode() { + return Objects.hash(mode, terms); + } +} diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index d84c9e47be8..d72783cfe9c 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -206,7 +206,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { user = new User(); event.setUser(user); } - if (user.getIpAddress() == null && options.isSendDefaultPii()) { + if (user.getIpAddress() == null && options.getDataCollectionResolver().isUserInfo()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index d7a16d4ee23..690d5e535ce 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -347,6 +347,11 @@ public class SentryOptions { /** whether to send personal identifiable information along with events */ private boolean sendDefaultPii = false; + private @NotNull DataCollection dataCollection = new DataCollection(false); + + private final @NotNull DataCollectionResolver dataCollectionResolver = + new DataCollectionResolver(this); + /** SSLSocketFactory for self-signed certificate trust * */ private @Nullable SSLSocketFactory sslSocketFactory; @@ -1751,6 +1756,33 @@ public void setSendDefaultPii(boolean sendDefaultPii) { this.sendDefaultPii = sendDefaultPii; } + /** + * Returns the configuration for data that the SDK collects automatically. + * + *

The returned object is always present. Accessing it does not configure data collection, but + * setting one of its options does. + */ + public @NotNull DataCollection getDataCollection() { + return dataCollection; + } + + /** + * Replaces the configuration for data that the SDK collects automatically. + * + *

Passing an empty {@link DataCollection} opts into the documented data-collection defaults. + */ + public void setDataCollection(final @NotNull DataCollection dataCollection) { + if (dataCollection != null) { + this.dataCollection = dataCollection; + } + } + + /** Returns the Data Collection policy resolver used by SDK integrations. */ + @ApiStatus.Internal + public @NotNull DataCollectionResolver getDataCollectionResolver() { + return dataCollectionResolver; + } + /** * Adds a Scope observer * @@ -3712,6 +3744,9 @@ public void merge(final @NotNull ExternalOptions options) { if (options.isSendDefaultPii() != null) { setSendDefaultPii(options.isSendDefaultPii()); } + if (options.getDataCollection() != null) { + mergeDataCollection(options.getDataCollection()); + } if (options.isCaptureOpenTelemetryEvents() != null) { setCaptureOpenTelemetryEvents(options.isCaptureOpenTelemetryEvents()); } @@ -3777,6 +3812,40 @@ public void merge(final @NotNull ExternalOptions options) { } } + private void mergeDataCollection(final @NotNull DataCollection externalDataCollection) { + if (externalDataCollection.getUserInfo() != null) { + dataCollection.setUserInfo(externalDataCollection.getUserInfo()); + } + if (externalDataCollection.getHttpBodies() != null) { + dataCollection.setHttpBodies(externalDataCollection.getHttpBodies()); + } + if (externalDataCollection.getCookies() != null) { + dataCollection.setCookies(externalDataCollection.getCookies()); + } + if (externalDataCollection.getHttpHeaders().getRequest() != null) { + dataCollection + .getHttpHeaders() + .setRequest(externalDataCollection.getHttpHeaders().getRequest()); + } + if (externalDataCollection.getHttpHeaders().getResponse() != null) { + dataCollection + .getHttpHeaders() + .setResponse(externalDataCollection.getHttpHeaders().getResponse()); + } + if (externalDataCollection.getUrlQueryParams() != null) { + dataCollection.setUrlQueryParams(externalDataCollection.getUrlQueryParams()); + } + if (externalDataCollection.getGraphql().getDocument() != null) { + dataCollection.getGraphql().setDocument(externalDataCollection.getGraphql().getDocument()); + } + if (externalDataCollection.getGraphql().getVariables() != null) { + dataCollection.getGraphql().setVariables(externalDataCollection.getGraphql().getVariables()); + } + if (externalDataCollection.getDatabaseQueryData() != null) { + dataCollection.setDatabaseQueryData(externalDataCollection.getDatabaseQueryData()); + } + } + private @NotNull SdkVersion createSdkVersion() { final String version = BuildConfig.VERSION_NAME; final SdkVersion sdkVersion = new SdkVersion(BuildConfig.SENTRY_JAVA_SDK_NAME, version); diff --git a/sentry/src/main/java/io/sentry/TraceContext.java b/sentry/src/main/java/io/sentry/TraceContext.java index b10954f5285..1bb5508f85b 100644 --- a/sentry/src/main/java/io/sentry/TraceContext.java +++ b/sentry/src/main/java/io/sentry/TraceContext.java @@ -1,7 +1,6 @@ package io.sentry; import io.sentry.protocol.SentryId; -import io.sentry.protocol.User; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.util.Map; @@ -81,16 +80,6 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { this.sampleRand = sampleRand; } - @SuppressWarnings("UnusedMethod") - private static @Nullable String getUserId( - final @NotNull SentryOptions options, final @Nullable User user) { - if (options.isSendDefaultPii() && user != null) { - return user.getId(); - } - - return null; - } - public @NotNull SentryId getTraceId() { return traceId; } diff --git a/sentry/src/main/java/io/sentry/util/CookieUtils.java b/sentry/src/main/java/io/sentry/util/CookieUtils.java new file mode 100644 index 00000000000..935f98aaf1a --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/CookieUtils.java @@ -0,0 +1,291 @@ +package io.sentry.util; + +import static io.sentry.util.UrlUtils.SENSITIVE_DATA_SUBSTITUTE; + +import io.sentry.KeyValueCollectionBehavior; +import io.sentry.SentryOptions; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Locale; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class CookieUtils { + + public static final String COOKIE_HEADER_NAME = "Cookie"; + + private static final List SECURITY_COOKIES = + Arrays.asList( + "JSESSIONID", + "JSESSIONIDSSO", + "JSSOSESSIONID", + "SESSIONID", + "SID", + "CSRFTOKEN", + "XSRF-TOKEN"); + + public static @Nullable List filterCookiesFromHeader( + final @Nullable Enumeration headers, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + return headers == null + ? null + : filterCookiesFromHeader( + Collections.list(headers), behavior, additionalSensitiveCookieNames); + } + + public static @Nullable List filterCookiesFromHeader( + final @Nullable List headers, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + if (headers == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + final @NotNull List filteredHeaders = new ArrayList<>(); + for (final String header : headers) { + final @Nullable String filteredHeader = + filterCookies(header, behavior, additionalSensitiveCookieNames); + if (filteredHeader != null) { + filteredHeaders.add(filteredHeader); + } + } + return filteredHeaders; + } + + public static @Nullable String filterCookies( + final @Nullable String cookies, final @NotNull SentryOptions options) { + if (!options.getDataCollectionResolver().isDataCollectionConfigured()) { + return options.isSendDefaultPii() ? cookies : null; + } + return filterCookies(cookies, options.getDataCollectionResolver().getCookies(), null); + } + + public static @Nullable String filterCookies( + final @Nullable String cookies, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + if (cookies == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + final @NotNull String[] cookieValues = cookies.split(";", -1); + final @NotNull StringBuilder filteredCookies = new StringBuilder(); + for (int i = 0; i < cookieValues.length; i++) { + if (i > 0) { + filteredCookies.append(';'); + } + filteredCookies.append( + filterCookie(cookieValues[i], behavior, additionalSensitiveCookieNames)); + } + return filteredCookies.toString(); + } + + public static @Nullable String filterSetCookie( + final @Nullable String cookie, final @NotNull SentryOptions options) { + if (!options.getDataCollectionResolver().isDataCollectionConfigured()) { + return options.isSendDefaultPii() ? cookie : null; + } + return filterSetCookie(cookie, options.getDataCollectionResolver().getCookies()); + } + + public static @Nullable String filterSetCookie( + final @Nullable String cookie, final @NotNull KeyValueCollectionBehavior behavior) { + if (cookie == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + final int attributesSeparator = cookie.indexOf(';'); + final @NotNull String cookieValue = + attributesSeparator < 0 ? cookie : cookie.substring(0, attributesSeparator); + if (!isValidCookiePair(cookieValue)) { + return SENSITIVE_DATA_SUBSTITUTE; + } + final @NotNull String attributes = + attributesSeparator < 0 ? "" : cookie.substring(attributesSeparator); + return filterCookie(cookieValue, behavior, null) + attributes; + } + + private static @NotNull String filterCookie( + final @NotNull String cookie, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + if (cookie.trim().isEmpty()) { + return cookie; + } + + if (!isValidCookiePair(cookie)) { + return SENSITIVE_DATA_SUBSTITUTE; + } + + final int separator = cookie.indexOf('='); + final @NotNull String name = cookie.substring(0, separator); + final @NotNull String normalizedName = name.trim(); + final boolean sensitive = + HttpUtils.containsSensitiveDataKey(normalizedName) + || isSecurityCookie(normalizedName, additionalSensitiveCookieNames); + final boolean matchesTerm = HttpUtils.containsTerm(normalizedName, behavior.getTerms()); + final boolean shouldFilter = + sensitive + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.DENY_LIST && matchesTerm) + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.ALLOW_LIST && !matchesTerm); + + if (shouldFilter) { + return name + "=" + SENSITIVE_DATA_SUBSTITUTE; + } + return cookie; + } + + private static boolean isValidCookiePair(final @NotNull String cookie) { + final @NotNull String cookiePair = cookie.trim(); + final int separator = cookiePair.indexOf('='); + if (separator <= 0 || !isValidCookieName(cookiePair.substring(0, separator))) { + return false; + } + + final @NotNull String value = cookiePair.substring(separator + 1); + int start = 0; + int end = value.length(); + if (!value.isEmpty() && value.charAt(0) == '"') { + if (value.length() < 2 || value.charAt(value.length() - 1) != '"') { + return false; + } + start++; + end--; + } + + for (int i = start; i < end; i++) { + if (!isCookieOctet(value.charAt(i))) { + return false; + } + } + return true; + } + + private static boolean isValidCookieName(final @NotNull String name) { + for (int i = 0; i < name.length(); i++) { + if (!isCookieNameCharacter(name.charAt(i))) { + return false; + } + } + return true; + } + + private static boolean isCookieNameCharacter(final char value) { + if ((value >= 'a' && value <= 'z') + || (value >= 'A' && value <= 'Z') + || (value >= '0' && value <= '9')) { + return true; + } + + switch (value) { + case '!': + case '#': + case '$': + case '%': + case '&': + case '\'': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '~': + return true; + default: + return false; + } + } + + private static boolean isCookieOctet(final char value) { + return value == 0x21 + || (value >= 0x23 && value <= 0x2B) + || (value >= 0x2D && value <= 0x3A) + || (value >= 0x3C && value <= 0x5B) + || (value >= 0x5D && value <= 0x7E); + } + + public static @Nullable List filterOutSecurityCookiesFromHeader( + final @Nullable Enumeration headers, + final @Nullable String headerName, + final @Nullable List additionalCookieNamesToFilter) { + if (headers == null) { + return null; + } + + return filterOutSecurityCookiesFromHeader( + Collections.list(headers), headerName, additionalCookieNamesToFilter); + } + + public static @Nullable List filterOutSecurityCookiesFromHeader( + final @Nullable List headers, + final @Nullable String headerName, + final @Nullable List additionalCookieNamesToFilter) { + if (headers == null) { + return null; + } + + if (headerName != null && !COOKIE_HEADER_NAME.equalsIgnoreCase(headerName)) { + return headers; + } + + final @NotNull ArrayList filteredHeaders = new ArrayList<>(); + for (final String header : headers) { + filteredHeaders.add(filterOutSecurityCookies(header, additionalCookieNamesToFilter)); + } + return filteredHeaders; + } + + public static @Nullable String filterOutSecurityCookies( + final @Nullable String cookieString, + final @Nullable List additionalCookieNamesToFilter) { + if (cookieString == null) { + return null; + } + + final @NotNull String[] cookies = cookieString.split(";", -1); + final @NotNull StringBuilder filteredCookieString = new StringBuilder(); + boolean isFirst = true; + for (String cookie : cookies) { + if (!isFirst) { + filteredCookieString.append(";"); + } + + final @NotNull String[] cookieParts = cookie.split("=", -1); + final @NotNull String cookieName = cookieParts[0]; + if (isSecurityCookie(cookieName.trim(), additionalCookieNamesToFilter)) { + filteredCookieString.append(cookieName + "=" + SENSITIVE_DATA_SUBSTITUTE); + } else { + filteredCookieString.append(cookie); + } + isFirst = false; + } + return filteredCookieString.toString(); + } + + public static boolean isSecurityCookie( + final @NotNull String cookieName, + final @Nullable List additionalCookieNamesToFilter) { + final @NotNull String cookieNameToSearchFor = cookieName.toUpperCase(Locale.ROOT); + if (SECURITY_COOKIES.contains(cookieNameToSearchFor)) { + return true; + } + + if (additionalCookieNamesToFilter != null) { + for (String additionalCookieName : additionalCookieNamesToFilter) { + if (additionalCookieName.toUpperCase(Locale.ROOT).equals(cookieNameToSearchFor)) { + return true; + } + } + } + return false; + } +} diff --git a/sentry/src/main/java/io/sentry/util/GraphqlUtils.java b/sentry/src/main/java/io/sentry/util/GraphqlUtils.java new file mode 100644 index 00000000000..faffc98afa5 --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/GraphqlUtils.java @@ -0,0 +1,79 @@ +package io.sentry.util; + +import io.sentry.DataCollectionResolver; +import io.sentry.JsonObjectReader; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class GraphqlUtils { + + private GraphqlUtils() {} + + public static @Nullable String filterRequestBody( + final @NotNull String body, final @NotNull SentryOptions options) { + final @NotNull DataCollectionResolver resolver = options.getDataCollectionResolver(); + final boolean includeDocument = resolver.isGraphqlDocumentWithLegacyAlways(); + final boolean includeVariables = resolver.isGraphqlVariablesWithLegacyAlways(); + + if (includeDocument && includeVariables) { + return body; + } + if (!includeDocument && !includeVariables) { + return null; + } + + try (JsonObjectReader reader = new JsonObjectReader(new StringReader(body))) { + final @Nullable Object value = reader.nextObjectOrNull(); + final @NotNull Object filtered; + if (value instanceof Map) { + @SuppressWarnings("unchecked") + final @NotNull Map requestBody = (Map) value; + filtered = filterRequest(requestBody, includeDocument, includeVariables); + } else if (value instanceof List) { + final @NotNull List> filteredBatch = new ArrayList<>(); + for (final @Nullable Object item : (List) value) { + if (!(item instanceof Map)) { + return null; + } + @SuppressWarnings("unchecked") + final @NotNull Map requestBody = (Map) item; + filteredBatch.add(filterRequest(requestBody, includeDocument, includeVariables)); + } + filtered = filteredBatch; + } else { + return null; + } + final @NotNull StringWriter writer = new StringWriter(); + options.getSerializer().serialize(filtered, writer); + return writer.toString(); + } catch (IOException | NumberFormatException e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to filter GraphQL request body.", e); + return null; + } + } + + private static @NotNull Map filterRequest( + final @NotNull Map request, + final boolean includeDocument, + final boolean includeVariables) { + final @NotNull Map filtered = new LinkedHashMap<>(request); + if (!includeDocument) { + filtered.remove("query"); + } + if (!includeVariables) { + filtered.remove("variables"); + } + return filtered; + } +} diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index 399ba7013fe..59ed67eb9f8 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -3,12 +3,14 @@ import static io.sentry.util.UrlUtils.SENSITIVE_DATA_SUBSTITUTE; import io.sentry.HttpStatusCodeRange; -import java.util.ArrayList; +import io.sentry.KeyValueCollectionBehavior; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; import java.util.Arrays; -import java.util.Collections; -import java.util.Enumeration; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -16,8 +18,6 @@ @ApiStatus.Internal public final class HttpUtils { - public static final String COOKIE_HEADER_NAME = "Cookie"; - private static final List SENSITIVE_HEADERS = Arrays.asList( "X-FORWARDED-FOR", @@ -33,15 +33,25 @@ public final class HttpUtils { "X-CSRFTOKEN", "X-XSRF-TOKEN"); - private static final List SECURITY_COOKIES = + private static final List SENSITIVE_DATA_KEYS = Arrays.asList( - "JSESSIONID", - "JSESSIONIDSSO", - "JSSOSESSIONID", - "SESSIONID", - "SID", - "CSRFTOKEN", - "XSRF-TOKEN"); + "auth", + "token", + "secret", + "password", + "passwd", + "pwd", + "key", + "jwt", + "bearer", + "sso", + "saml", + "csrf", + "xsrf", + "credentials", + "session", + "sid", + "identity"); private static final HttpStatusCodeRange CLIENT_ERROR_STATUS_CODES = new HttpStatusCodeRange(400, 499); @@ -53,88 +63,89 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { return SENSITIVE_HEADERS.contains(header.toUpperCase(Locale.ROOT)); } - public static @Nullable List filterOutSecurityCookiesFromHeader( - final @Nullable Enumeration headers, - final @Nullable String headerName, - final @Nullable List additionalCookieNamesToFilter) { - if (headers == null) { + public static @Nullable String filterQueryParams( + final @Nullable String query, final @NotNull KeyValueCollectionBehavior behavior) { + if (query == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { return null; } - return filterOutSecurityCookiesFromHeader( - Collections.list(headers), headerName, additionalCookieNamesToFilter); - } + final @NotNull StringBuilder filteredQuery = new StringBuilder(); + final @NotNull String[] params = query.split("&", -1); + for (int i = 0; i < params.length; i++) { + if (i > 0) { + filteredQuery.append('&'); + } - public static @Nullable List filterOutSecurityCookiesFromHeader( - final @Nullable List headers, - final @Nullable String headerName, - final @Nullable List additionalCookieNamesToFilter) { - if (headers == null) { - return null; + final @NotNull String param = params[i]; + final int separator = param.indexOf('='); + final @NotNull String name = separator < 0 ? param : param.substring(0, separator); + final @NotNull String decodedName = decodeQueryParamName(name); + final boolean sensitive = containsTerm(decodedName, SENSITIVE_DATA_KEYS); + final boolean matchesTerm = containsTerm(decodedName, behavior.getTerms()); + final boolean shouldFilter = + sensitive + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.DENY_LIST && matchesTerm) + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.ALLOW_LIST && !matchesTerm); + + filteredQuery.append(name); + if (shouldFilter) { + filteredQuery.append('=').append(SENSITIVE_DATA_SUBSTITUTE); + } else if (separator >= 0) { + filteredQuery.append(param.substring(separator)); + } } + return filteredQuery.toString(); + } - if (headerName != null && !"Cookie".equalsIgnoreCase(headerName)) { - return headers; + public static @NotNull Map filterHeaders( + final @NotNull Map headers, + final @NotNull KeyValueCollectionBehavior behavior) { + final @NotNull Map filteredHeaders = new LinkedHashMap<>(); + if (behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return filteredHeaders; } - final @NotNull ArrayList filteredHeaders = new ArrayList<>(); - - for (final String header : headers) { - filteredHeaders.add( - HttpUtils.filterOutSecurityCookies(header, additionalCookieNamesToFilter)); + for (final Map.Entry header : headers.entrySet()) { + final @NotNull String name = header.getKey(); + final boolean sensitive = + containsTerm(name, SENSITIVE_DATA_KEYS) + || "Cookie".equalsIgnoreCase(name) + || "Set-Cookie".equalsIgnoreCase(name); + if (sensitive) { + filteredHeaders.put(name, SENSITIVE_DATA_SUBSTITUTE); + } else { + final boolean matchesTerm = containsTerm(name, behavior.getTerms()); + final boolean shouldFilter = + behavior.getMode() == KeyValueCollectionBehavior.Mode.DENY_LIST + ? matchesTerm + : !matchesTerm; + filteredHeaders.put(name, shouldFilter ? SENSITIVE_DATA_SUBSTITUTE : header.getValue()); + } } - return filteredHeaders; } - public static @Nullable String filterOutSecurityCookies( - final @Nullable String cookieString, - final @Nullable List additionalCookieNamesToFilter) { - if (cookieString == null) { - return null; - } + private static @NotNull String decodeQueryParamName(final @NotNull String name) { try { - final @NotNull String[] cookies = cookieString.split(";", -1); - final @NotNull StringBuilder filteredCookieString = new StringBuilder(); - boolean isFirst = true; - - for (String cookie : cookies) { - if (!isFirst) { - filteredCookieString.append(";"); - } - - final @NotNull String[] cookieParts = cookie.split("=", -1); - final @NotNull String cookieName = cookieParts[0]; - if (isSecurityCookie(cookieName.trim(), additionalCookieNamesToFilter)) { - filteredCookieString.append(cookieName + "=" + SENSITIVE_DATA_SUBSTITUTE); - } else { - filteredCookieString.append(cookie); - } - isFirst = false; - } - - return filteredCookieString.toString(); - } catch (Throwable t) { - return null; + return URLDecoder.decode(name, "UTF-8"); + } catch (IllegalArgumentException | UnsupportedEncodingException ignored) { + return name; } } - public static boolean isSecurityCookie( - final @NotNull String cookieName, - final @Nullable List additionalCookieNamesToFilter) { - final @NotNull String cookieNameToSearchFor = cookieName.toUpperCase(Locale.ROOT); - if (SECURITY_COOKIES.contains(cookieNameToSearchFor)) { - return true; - } + static boolean containsSensitiveDataKey(final @NotNull String key) { + return containsTerm(key, SENSITIVE_DATA_KEYS); + } - if (additionalCookieNamesToFilter != null) { - for (String additionalCookieName : additionalCookieNamesToFilter) { - if (additionalCookieName.toUpperCase(Locale.ROOT).equals(cookieNameToSearchFor)) { - return true; - } + static boolean containsTerm(final @NotNull String key, final @NotNull List terms) { + final @NotNull String normalizedKey = key.toLowerCase(Locale.ROOT); + for (final String term : terms) { + if (term != null + && !term.isEmpty() + && normalizedKey.contains(term.toLowerCase(Locale.ROOT))) { + return true; } } - return false; } diff --git a/sentry/src/main/java/io/sentry/util/UrlUtils.java b/sentry/src/main/java/io/sentry/util/UrlUtils.java index 6c70cea0495..289a80a17fd 100644 --- a/sentry/src/main/java/io/sentry/util/UrlUtils.java +++ b/sentry/src/main/java/io/sentry/util/UrlUtils.java @@ -1,5 +1,6 @@ package io.sentry.util; +import io.sentry.DataCollectionResolver; import io.sentry.ISpan; import io.sentry.SpanDataConvention; import io.sentry.protocol.Request; @@ -18,6 +19,11 @@ public final class UrlUtils { } public static @NotNull UrlDetails parse(final @NotNull String url) { + return parse(url, null); + } + + public static @NotNull UrlDetails parse( + final @NotNull String url, final @Nullable DataCollectionResolver resolver) { try { URI uri = new URI(url); if (uri.isAbsolute() && !isValidAbsoluteUrl(uri)) { @@ -28,7 +34,9 @@ public final class UrlUtils { uri.getScheme() == null ? "" : (uri.getScheme() + "://"); final @NotNull String authority = uri.getRawAuthority() == null ? "" : uri.getRawAuthority(); final @NotNull String path = uri.getRawPath() == null ? "" : uri.getRawPath(); - final @Nullable String query = uri.getRawQuery(); + final @Nullable String rawQuery = uri.getRawQuery(); + final @Nullable String query = + resolver == null ? rawQuery : filterQueryParams(rawQuery, resolver); final @Nullable String fragment = uri.getRawFragment(); final @NotNull String filteredUrl = schemeAndSeparator + filterUserInfo(authority) + path; @@ -39,7 +47,19 @@ public final class UrlUtils { } } + public static @Nullable String filterQueryParams( + final @Nullable String query, final @NotNull DataCollectionResolver resolver) { + return resolver.isDataCollectionConfigured() + ? HttpUtils.filterQueryParams(query, resolver.getUrlQueryParams()) + : query; + } + private static boolean isValidAbsoluteUrl(final @NotNull URI uri) { + final @Nullable String scheme = uri.getScheme(); + if ("ws".equalsIgnoreCase(scheme) || "wss".equalsIgnoreCase(scheme)) { + return !uri.isOpaque() && uri.getRawAuthority() != null && !uri.getRawAuthority().isEmpty(); + } + try { uri.toURL(); } catch (Exception e) { diff --git a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt new file mode 100644 index 00000000000..25082d7f875 --- /dev/null +++ b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt @@ -0,0 +1,368 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test + +class DataCollectionResolverTest { + @Test + fun `one resolver is reused per options instance`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver).isSameInstanceAs(options.dataCollectionResolver) + } + + @Test + fun `each options instance owns its resolver`() { + val first = SentryOptions() + val second = SentryOptions() + + assertThat(first.dataCollectionResolver).isNotSameInstanceAs(second.dataCollectionResolver) + } + + @Test + fun `data collection configured reflects namespace explicitness`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.isDataCollectionConfigured).isFalse() + + options.dataCollection.urlQueryParams = KeyValueCollectionBehavior.denyList() + + assertThat(options.dataCollectionResolver.isDataCollectionConfigured).isTrue() + } + + @Test + fun `user info falls back to sendDefaultPii when unset`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.isUserInfo).isFalse() + + options.isSendDefaultPii = true + + assertThat(options.dataCollectionResolver.isUserInfo).isTrue() + } + + @Test + fun `user info uses configured Data Collection value`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + options.dataCollection.setUserInfo(false) + + assertThat(options.dataCollectionResolver.isUserInfo).isFalse() + + options.isSendDefaultPii = false + options.dataCollection.setUserInfo(true) + + assertThat(options.dataCollectionResolver.isUserInfo).isTrue() + } + + @Test + fun `omitted booleans use data collection defaults once namespace is explicit`() { + val options = SentryOptions().apply { isSendDefaultPii = false } + + options.dataCollection.cookies = KeyValueCollectionBehavior.off() + + assertThat(options.dataCollectionResolver.isUserInfo).isTrue() + assertThat(options.dataCollectionResolver.isDatabaseQueryData).isTrue() + assertThat(options.dataCollectionResolver.isGraphqlDocument).isTrue() + assertThat(options.dataCollectionResolver.isGraphqlVariables).isTrue() + } + + @Test + fun `database query data uses sendDefaultPii when Data Collection is absent`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.isDatabaseQueryData).isFalse() + + options.isSendDefaultPii = true + + assertThat(options.dataCollectionResolver.isDatabaseQueryData).isTrue() + } + + @Test + fun `database query data uses configured Data Collection value`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + options.dataCollection.setDatabaseQueryData(false) + + assertThat(options.dataCollectionResolver.isDatabaseQueryData).isFalse() + + options.isSendDefaultPii = false + options.dataCollection.setDatabaseQueryData(true) + + assertThat(options.dataCollectionResolver.isDatabaseQueryData).isTrue() + } + + @Test + fun `GraphQL document uses sendDefaultPii when Data Collection is absent`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.isGraphqlDocument).isFalse() + + options.isSendDefaultPii = true + + assertThat(options.dataCollectionResolver.isGraphqlDocument).isTrue() + } + + @Test + fun `GraphQL document uses configured Data Collection value`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + options.dataCollection.graphql.setDocument(false) + + assertThat(options.dataCollectionResolver.isGraphqlDocument).isFalse() + + options.isSendDefaultPii = false + options.dataCollection.graphql.setDocument(true) + + assertThat(options.dataCollectionResolver.isGraphqlDocument).isTrue() + } + + @Test + fun `GraphQL variables use sendDefaultPii when Data Collection is absent`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.isGraphqlVariables).isFalse() + + options.isSendDefaultPii = true + + assertThat(options.dataCollectionResolver.isGraphqlVariables).isTrue() + } + + @Test + fun `GraphQL variables use configured Data Collection value`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + options.dataCollection.graphql.setVariables(false) + + assertThat(options.dataCollectionResolver.isGraphqlVariables).isFalse() + + options.isSendDefaultPii = false + options.dataCollection.graphql.setVariables(true) + + assertThat(options.dataCollectionResolver.isGraphqlVariables).isTrue() + } + + @Test + fun `GraphQL legacy body variants preserve the legacy size gate when namespace is absent`() { + val options = + SentryOptions().apply { + isSendDefaultPii = true + maxRequestBodySize = SentryOptions.RequestSize.NONE + } + + assertThat(options.dataCollectionResolver.isGraphqlDocumentWithLegacyBodyGate).isFalse() + assertThat(options.dataCollectionResolver.isGraphqlVariablesWithLegacyBodyGate).isFalse() + + options.maxRequestBodySize = SentryOptions.RequestSize.SMALL + + assertThat(options.dataCollectionResolver.isGraphqlDocumentWithLegacyBodyGate).isTrue() + assertThat(options.dataCollectionResolver.isGraphqlVariablesWithLegacyBodyGate).isTrue() + } + + @Test + fun `outgoing response legacy body variant preserves the legacy size gate`() { + val options = + SentryOptions().apply { + isSendDefaultPii = true + maxRequestBodySize = SentryOptions.RequestSize.NONE + } + + assertThat(options.dataCollectionResolver.isOutgoingResponseBodyWithLegacyBodyGate).isFalse() + + options.maxRequestBodySize = SentryOptions.RequestSize.SMALL + + assertThat(options.dataCollectionResolver.isOutgoingResponseBodyWithLegacyBodyGate).isTrue() + } + + @Test + fun `outgoing response legacy body variant uses data collection when namespace is explicit`() { + val options = + SentryOptions().apply { + isSendDefaultPii = false + maxRequestBodySize = SentryOptions.RequestSize.NONE + dataCollection.graphql.setDocument(true) + } + + assertThat(options.dataCollectionResolver.isOutgoingResponseBodyWithLegacyBodyGate).isTrue() + + options.dataCollection.httpBodies = emptySet() + + assertThat(options.dataCollectionResolver.isOutgoingResponseBodyWithLegacyBodyGate).isFalse() + } + + @Test + fun `GraphQL legacy body variants ignore the size option when namespace is explicit`() { + val options = + SentryOptions().apply { + isSendDefaultPii = false + maxRequestBodySize = SentryOptions.RequestSize.NONE + dataCollection.graphql.setDocument(true) + dataCollection.graphql.setVariables(true) + } + + assertThat(options.dataCollectionResolver.isGraphqlDocumentWithLegacyBodyGate).isTrue() + assertThat(options.dataCollectionResolver.isGraphqlVariablesWithLegacyBodyGate).isTrue() + } + + @Test + fun `GraphQL document legacy always variant preserves collection when namespace is absent`() { + val options = SentryOptions().apply { isSendDefaultPii = false } + + assertThat(options.dataCollectionResolver.isGraphqlDocumentWithLegacyAlways).isTrue() + + options.dataCollection.graphql.setDocument(false) + + assertThat(options.dataCollectionResolver.isGraphqlDocumentWithLegacyAlways).isFalse() + } + + @Test + fun `GraphQL variables legacy always variant preserves collection when namespace is absent`() { + val options = SentryOptions().apply { isSendDefaultPii = false } + + assertThat(options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways).isTrue() + + options.dataCollection.graphql.setVariables(false) + + assertThat(options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways).isFalse() + } + + @Test + fun `cookies are off when unset and sendDefaultPii is false`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.cookies).isEqualTo(KeyValueCollectionBehavior.off()) + } + + @Test + fun `mutating one fallback key-value behavior does not affect other getters`() { + val resolver = SentryOptions().apply { dataCollection.setUserInfo(true) }.dataCollectionResolver + + resolver.cookies.terms = listOf("custom-cookie") + + assertThat(resolver.urlQueryParams.terms).doesNotContain("custom-cookie") + assertThat(resolver.httpRequestHeaders.terms).doesNotContain("custom-cookie") + assertThat(resolver.httpResponseHeaders.terms).doesNotContain("custom-cookie") + } + + @Test + fun `cookies use default deny list when unset and sendDefaultPii is true`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + assertThat(options.dataCollectionResolver.cookies) + .isEqualTo(KeyValueCollectionBehavior.denyList()) + } + + @Test + fun `cookies use default deny list when namespace is explicit`() { + val options = SentryOptions().apply { isSendDefaultPii = false } + + options.dataCollection.setUserInfo(false) + + assertThat(options.dataCollectionResolver.cookies) + .isEqualTo(KeyValueCollectionBehavior.denyList()) + } + + @Test + fun `cookies use configured Data Collection behavior`() { + val options = SentryOptions().apply { isSendDefaultPii = false } + val behavior = KeyValueCollectionBehavior.allowList("language", "theme") + + options.dataCollection.cookies = behavior + + assertThat(options.dataCollectionResolver.cookies).isEqualTo(behavior) + } + + @Test + fun `URL query params use default deny list when unset`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.urlQueryParams) + .isEqualTo(KeyValueCollectionBehavior.denyList()) + } + + @Test + fun `URL query params use configured Data Collection behavior`() { + val options = SentryOptions() + val behavior = KeyValueCollectionBehavior.allowList("language", "theme") + + options.dataCollection.urlQueryParams = behavior + + assertThat(options.dataCollectionResolver.urlQueryParams).isEqualTo(behavior) + } + + @Test + fun `HTTP request headers use default deny list when unset`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.httpRequestHeaders) + .isEqualTo(KeyValueCollectionBehavior.denyList()) + } + + @Test + fun `HTTP request headers use configured Data Collection behavior`() { + val options = SentryOptions() + val behavior = KeyValueCollectionBehavior.allowList("content-type") + + options.dataCollection.httpHeaders.request = behavior + + assertThat(options.dataCollectionResolver.httpRequestHeaders).isEqualTo(behavior) + } + + @Test + fun `HTTP response headers use default deny list when unset`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.httpResponseHeaders) + .isEqualTo(KeyValueCollectionBehavior.denyList()) + } + + @Test + fun `HTTP response headers use configured Data Collection behavior`() { + val options = SentryOptions() + val behavior = KeyValueCollectionBehavior.off() + + options.dataCollection.httpHeaders.response = behavior + + assertThat(options.dataCollectionResolver.httpResponseHeaders).isEqualTo(behavior) + } + + @Test + fun `HTTP bodies preserve direction-specific legacy fallbacks when data collection is absent`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.isIncomingRequestBody).isFalse() + assertThat(options.dataCollectionResolver.isOutgoingRequestBody).isTrue() + assertThat(options.dataCollectionResolver.isIncomingResponseBody).isTrue() + assertThat(options.dataCollectionResolver.isOutgoingResponseBody).isFalse() + + options.isSendDefaultPii = true + + assertThat(options.dataCollectionResolver.isIncomingRequestBody).isTrue() + assertThat(options.dataCollectionResolver.isOutgoingRequestBody).isTrue() + assertThat(options.dataCollectionResolver.isIncomingResponseBody).isTrue() + assertThat(options.dataCollectionResolver.isOutgoingResponseBody).isTrue() + } + + @Test + fun `explicit empty data collection enables every HTTP body direction`() { + val options = SentryOptions().apply { dataCollection = DataCollection() } + + assertThat(options.dataCollectionResolver.isIncomingRequestBody).isTrue() + assertThat(options.dataCollectionResolver.isOutgoingRequestBody).isTrue() + assertThat(options.dataCollectionResolver.isIncomingResponseBody).isTrue() + assertThat(options.dataCollectionResolver.isOutgoingResponseBody).isTrue() + } + + @Test + fun `explicit HTTP body set controls every direction`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + options.dataCollection.httpBodies = + setOf(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + + assertThat(options.dataCollectionResolver.isIncomingRequestBody).isTrue() + assertThat(options.dataCollectionResolver.isOutgoingRequestBody).isFalse() + assertThat(options.dataCollectionResolver.isIncomingResponseBody).isFalse() + assertThat(options.dataCollectionResolver.isOutgoingResponseBody).isTrue() + } +} diff --git a/sentry/src/test/java/io/sentry/DataCollectionTest.kt b/sentry/src/test/java/io/sentry/DataCollectionTest.kt new file mode 100644 index 00000000000..54366a8e136 --- /dev/null +++ b/sentry/src/test/java/io/sentry/DataCollectionTest.kt @@ -0,0 +1,103 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class DataCollectionTest { + @Test + fun `public constructor forces Data Collection for empty configuration`() { + val dataCollection = DataCollection() + + assertThat(dataCollection.userInfo).isNull() + assertThat(dataCollection.cookies).isNull() + assertThat(dataCollection.urlQueryParams).isNull() + assertThat(dataCollection.httpBodies).isNull() + assertThat(dataCollection.databaseQueryData).isNull() + assertThat(dataCollection.httpHeaders.request).isNull() + assertThat(dataCollection.httpHeaders.response).isNull() + assertThat(dataCollection.graphql.document).isNull() + assertThat(dataCollection.graphql.variables).isNull() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `SDK-owned configuration does not force Data Collection`() { + val dataCollection = DataCollection(false) + + assertThat(dataCollection.isExplicitlyConfigured()).isFalse() + } + + @Test + fun `nested override makes SDK-owned configuration explicit`() { + val dataCollection = DataCollection(false) + + dataCollection.graphql.setVariables(false) + + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `explicit false is distinct from unset`() { + val dataCollection = DataCollection(false) + + dataCollection.setUserInfo(false) + + assertThat(dataCollection.userInfo).isFalse() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `empty HTTP body set is distinct from unset`() { + val dataCollection = DataCollection(false) + + dataCollection.setHttpBodies(emptySet()) + + assertThat(dataCollection.httpBodies).isEmpty() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `HTTP body set is copied and immutable`() { + val bodies = mutableSetOf(HttpBodyType.INCOMING_REQUEST) + val dataCollection = DataCollection() + + dataCollection.setHttpBodies(bodies) + bodies += HttpBodyType.OUTGOING_REQUEST + + assertThat(dataCollection.httpBodies).containsExactly(HttpBodyType.INCOMING_REQUEST) + assertFailsWith { + dataCollection.httpBodies!!.add(HttpBodyType.OUTGOING_REQUEST) + } + } + + @Test + fun `database query data false is distinct from unset`() { + val dataCollection = DataCollection(false) + + dataCollection.setDatabaseQueryData(false) + + assertThat(dataCollection.databaseQueryData).isFalse() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `nested HTTP header override marks configuration explicit`() { + val dataCollection = DataCollection(false) + val behavior = KeyValueCollectionBehavior.denyList("authorization") + + dataCollection.httpHeaders.setRequest(behavior) + + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `nested GraphQL false marks configuration explicit`() { + val dataCollection = DataCollection(false) + + dataCollection.graphql.setVariables(false) + + assertThat(dataCollection.graphql.variables).isFalse() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } +} diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index fee707d31f3..b4b800d589d 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -1,9 +1,11 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.config.PropertiesProviderFactory import java.lang.RuntimeException import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -15,6 +17,98 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify class ExternalOptionsTest { + @Test + fun `does not create data collection when external properties are absent`() { + withPropertiesFile { assertThat(it.dataCollection).isNull() } + } + + @Test + fun `creates data collection using external properties`() { + withPropertiesFile( + listOf( + "data-collection.user-info=false", + "data-collection.http-bodies=incoming_request,outgoing_response", + "data-collection.cookies.mode=deny_list", + "data-collection.cookies.terms=authorization,session", + "data-collection.http-headers.request.mode=allow_list", + "data-collection.http-headers.request.terms=x-request-id,content-type", + "data-collection.http-headers.response.mode=off", + "data-collection.url-query-params.terms=search", + "data-collection.graphql.document=false", + "data-collection.graphql.variables=true", + "data-collection.database-query-data=false", + ) + ) { options -> + val dataCollection = options.dataCollection + + assertThat(dataCollection).isNotNull() + assertThat(dataCollection!!.userInfo).isFalse() + assertThat(dataCollection.httpBodies) + .containsExactly(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + assertThat(dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.denyList("authorization", "session")) + assertThat(dataCollection.httpHeaders.request) + .isEqualTo(KeyValueCollectionBehavior.allowList("x-request-id", "content-type")) + assertThat(dataCollection.httpHeaders.response).isEqualTo(KeyValueCollectionBehavior.off()) + assertThat(dataCollection.urlQueryParams) + .isEqualTo(KeyValueCollectionBehavior.denyList("search")) + assertThat(dataCollection.graphql.document).isFalse() + assertThat(dataCollection.graphql.variables).isTrue() + assertThat(dataCollection.databaseQueryData).isFalse() + } + } + + @Test + fun `empty HTTP bodies externally disables body collection`() { + withPropertiesFile("data-collection.http-bodies=") { options -> + assertThat(options.dataCollection).isNotNull() + assertThat(options.dataCollection!!.httpBodies).isEmpty() + } + } + + @Test + fun `invalid HTTP body type fails external parsing`() { + assertFailsWith { + withPropertiesFile("data-collection.http-bodies=invalid") {} + } + } + + @Test + fun `invalid collection mode fails external parsing`() { + assertFailsWith { + withPropertiesFile("data-collection.cookies.mode=invalid") {} + } + } + + @Test + fun `data collection booleans use default external parsing`() { + withPropertiesFile( + listOf( + "data-collection.user-info=invalid", + "data-collection.graphql.document=invalid", + "data-collection.graphql.variables=invalid", + "data-collection.database-query-data=invalid", + ) + ) { options -> + assertThat(options.dataCollection!!.userInfo).isFalse() + assertThat(options.dataCollection!!.graphql.document).isFalse() + assertThat(options.dataCollection!!.graphql.variables).isFalse() + assertThat(options.dataCollection!!.databaseQueryData).isFalse() + } + } + + @Test + fun `external data collection takes precedence over external send default PII`() { + withPropertiesFile(listOf("send-default-pii=false", "data-collection.cookies.mode=off")) { + externalOptions -> + val options = SentryOptions().apply { merge(externalOptions) } + + assertThat(options.isSendDefaultPii).isFalse() + assertThat(options.dataCollectionResolver.isUserInfo).isTrue() + assertThat(options.dataCollectionResolver.cookies).isEqualTo(KeyValueCollectionBehavior.off()) + } + } + @Test fun `creates options with proxy using external properties`() { withPropertiesFile( diff --git a/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt b/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt new file mode 100644 index 00000000000..09cd92f4fce --- /dev/null +++ b/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt @@ -0,0 +1,70 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test + +class KeyValueCollectionBehaviorTest { + @Test + fun `default constructor uses deny list with no terms`() { + val behavior = KeyValueCollectionBehavior() + + assertThat(behavior.mode).isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) + assertThat(behavior.terms).isEmpty() + } + + @Test + fun `setters update mode and defensively copy terms`() { + val terms = mutableListOf("token") + val behavior = KeyValueCollectionBehavior() + + behavior.mode = KeyValueCollectionBehavior.Mode.ALLOW_LIST + behavior.terms = terms + terms[0] = "password" + + assertThat(behavior.mode).isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(behavior.terms).containsExactly("token") + } + + @Test + fun `off has no terms`() { + val behavior = KeyValueCollectionBehavior.off() + + assertThat(behavior.mode).isEqualTo(KeyValueCollectionBehavior.Mode.OFF) + assertThat(behavior.terms).isEmpty() + } + + @Test + fun `deny list stores terms in order`() { + val behavior = KeyValueCollectionBehavior.denyList("token", "session") + + assertThat(behavior.mode).isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) + assertThat(behavior.terms).containsExactly("token", "session").inOrder() + } + + @Test + fun `allow list can be empty`() { + val behavior = KeyValueCollectionBehavior.allowList() + + assertThat(behavior.mode).isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(behavior.terms).isEmpty() + } + + @Test + fun `terms are copied and immutable`() { + val terms = arrayOf("token") + val behavior = KeyValueCollectionBehavior.denyList(*terms) + + terms[0] = "password" + + assertThat(behavior.terms).containsExactly("token") + } + + @Test + fun `equal behaviors have equal hash codes`() { + val first = KeyValueCollectionBehavior.allowList("language", "theme") + val second = KeyValueCollectionBehavior.allowList("language", "theme") + + assertThat(first).isEqualTo(second) + assertThat(first.hashCode()).isEqualTo(second.hashCode()) + } +} diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index fe5c835c90f..643b850e86e 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -1,5 +1,6 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.hints.AbnormalExit import io.sentry.hints.ApplyScopeData import io.sentry.protocol.DebugMeta @@ -321,6 +322,39 @@ class MainEventProcessorTest { assertNotNull(event.user) { assertNull(it.ipAddress) } } + @Test + fun `when user info is disabled, do not enrich ip address if sendDefaultPii is true`() { + fixture.sentryOptions.dataCollection.setUserInfo(false) + val sut = fixture.getSut(sendDefaultPii = true) + val event = SentryEvent() + + sut.process(event, Hint()) + + assertThat(event.user?.ipAddress).isNull() + } + + @Test + fun `when user info is enabled, enrich ip address if sendDefaultPii is false`() { + fixture.sentryOptions.dataCollection.setUserInfo(true) + val sut = fixture.getSut(sendDefaultPii = false) + val event = SentryEvent() + + sut.process(event, Hint()) + + assertThat(event.user?.ipAddress).isEqualTo("{{auto}}") + } + + @Test + fun `when another data collection setting is configured, omitted user info uses its default`() { + fixture.sentryOptions.dataCollection.cookies = KeyValueCollectionBehavior.off() + val sut = fixture.getSut(sendDefaultPii = false) + val event = SentryEvent() + + sut.process(event, Hint()) + + assertThat(event.user?.ipAddress).isEqualTo("{{auto}}") + } + @Test fun `when event has ip address set, keeps original ip address`() { val sut = fixture.getSut(sendDefaultPii = true) diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 64482b5d5d0..1416fb00830 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -1,5 +1,6 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.SentryOptions.RequestSize import io.sentry.logger.ILoggerBatchProcessorFactory import io.sentry.util.StringUtils @@ -20,6 +21,148 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify class SentryOptionsTest { + @Test + fun `data collection is always present without being explicitly configured`() { + val options = SentryOptions() + + assertThat(options.dataCollection).isNotNull() + assertThat(options.dataCollection.isExplicitlyConfigured()).isFalse() + } + + @Test + fun `data collection getter returns the same instance`() { + val options = SentryOptions() + + assertThat(options.dataCollection).isSameInstanceAs(options.dataCollection) + assertThat(options.dataCollection.isExplicitlyConfigured()).isFalse() + } + + @Test + fun `setting a data collection override marks it explicitly configured`() { + val options = SentryOptions() + + options.dataCollection.setUserInfo(false) + + assertThat(options.dataCollection.userInfo).isFalse() + assertThat(options.dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `setting an empty data collection marks it explicitly configured`() { + val options = SentryOptions() + + options.dataCollection = DataCollection() + + assertThat(options.dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `setting data collection replaces the default instance`() { + val options = SentryOptions() + val dataCollection = DataCollection().apply { setUserInfo(false) } + + options.dataCollection = dataCollection + + assertThat(options.dataCollection).isSameInstanceAs(dataCollection) + assertThat(options.dataCollection.userInfo).isFalse() + } + + @Test + fun `setting null data collection preserves the current instance`() { + val options = SentryOptions() + val dataCollection = DataCollection().apply { setUserInfo(false) } + options.dataCollection = dataCollection + + SentryOptions::class + .java + .getMethod("setDataCollection", DataCollection::class.java) + .invoke(options, null) + + assertThat(options.dataCollection).isSameInstanceAs(dataCollection) + assertThat(options.dataCollection.userInfo).isFalse() + } + + @Test + fun `merging absent external data collection preserves legacy mode`() { + val options = SentryOptions() + + options.merge(ExternalOptions()) + + assertThat(options.dataCollection.isExplicitlyConfigured()).isFalse() + } + + @Test + fun `merging external data collection applies only configured values`() { + val options = + SentryOptions().apply { + dataCollection.setUserInfo(false) + dataCollection.cookies = KeyValueCollectionBehavior.allowList("safe") + } + val externalOptions = + ExternalOptions().apply { + dataCollection = DataCollection().apply { graphql.setVariables(false) } + } + + options.merge(externalOptions) + + assertThat(options.dataCollection.userInfo).isFalse() + assertThat(options.dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.allowList("safe")) + assertThat(options.dataCollection.graphql.variables).isFalse() + } + + @Test + fun `merging external data collection applies every supported value`() { + val externalDataCollection = + DataCollection().apply { + setUserInfo(false) + httpBodies = setOf(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + cookies = KeyValueCollectionBehavior.denyList("cookie") + httpHeaders.request = KeyValueCollectionBehavior.allowList("request") + httpHeaders.response = KeyValueCollectionBehavior.off() + urlQueryParams = KeyValueCollectionBehavior.denyList("query") + graphql.setDocument(false) + graphql.setVariables(false) + setDatabaseQueryData(false) + } + val options = SentryOptions() + + options.merge(ExternalOptions().apply { dataCollection = externalDataCollection }) + + assertThat(options.dataCollection.userInfo).isFalse() + assertThat(options.dataCollection.httpBodies) + .containsExactly(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + assertThat(options.dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.denyList("cookie")) + assertThat(options.dataCollection.httpHeaders.request) + .isEqualTo(KeyValueCollectionBehavior.allowList("request")) + assertThat(options.dataCollection.httpHeaders.response) + .isEqualTo(KeyValueCollectionBehavior.off()) + assertThat(options.dataCollection.urlQueryParams) + .isEqualTo(KeyValueCollectionBehavior.denyList("query")) + assertThat(options.dataCollection.graphql.document).isFalse() + assertThat(options.dataCollection.graphql.variables).isFalse() + assertThat(options.dataCollection.databaseQueryData).isFalse() + } + + @Test + fun `external data collection takes precedence over send default PII`() { + val externalOptions = + ExternalOptions().apply { + isSendDefaultPii = false + dataCollection = DataCollection().apply { cookies = KeyValueCollectionBehavior.off() } + } + val options = SentryOptions() + + options.merge(externalOptions) + + assertThat(options.isSendDefaultPii).isFalse() + assertThat(options.dataCollection.isExplicitlyConfigured()).isTrue() + assertThat(options.dataCollectionResolver.isUserInfo).isTrue() + assertThat(options.dataCollectionResolver.isDatabaseQueryData).isTrue() + assertThat(options.dataCollectionResolver.cookies).isEqualTo(KeyValueCollectionBehavior.off()) + } + @Test fun `when options is initialized, logger is not null`() { assertNotNull(SentryOptions().logger) diff --git a/sentry/src/test/java/io/sentry/util/CookieUtilsTest.kt b/sentry/src/test/java/io/sentry/util/CookieUtilsTest.kt new file mode 100644 index 00000000000..0c000d31e41 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/CookieUtilsTest.kt @@ -0,0 +1,424 @@ +package io.sentry.util + +import com.google.common.truth.Truth.assertThat +import io.sentry.KeyValueCollectionBehavior +import io.sentry.SentryOptions +import java.util.Enumeration +import java.util.StringTokenizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class CookieUtilsTest { + @Test + fun `options cookie filters omit cookies in legacy mode without default pii`() { + val options = SentryOptions() + + assertThat(CookieUtils.filterCookies("sessionId=secret", options)).isNull() + assertThat(CookieUtils.filterSetCookie("sessionId=secret; Path=/", options)).isNull() + } + + @Test + fun `options cookie filters preserve cookies in legacy mode with default pii`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + assertThat(CookieUtils.filterCookies("sessionId=secret", options)).isEqualTo("sessionId=secret") + assertThat(CookieUtils.filterSetCookie("sessionId=secret; Path=/", options)) + .isEqualTo("sessionId=secret; Path=/") + } + + @Test + fun `options cookie filters apply configured policy`() { + val options = + SentryOptions().apply { + dataCollection.cookies = KeyValueCollectionBehavior.denyList("customer") + } + + assertThat(CookieUtils.filterCookies("theme=dark; customerId=123", options)) + .isEqualTo("theme=dark; customerId=[Filtered]") + assertThat(CookieUtils.filterSetCookie("customerId=123; Path=/", options)) + .isEqualTo("customerId=[Filtered]; Path=/") + } + + @Test + fun `options cookie filters honor explicitly disabled collection`() { + val options = + SentryOptions().apply { + isSendDefaultPii = true + dataCollection.cookies = KeyValueCollectionBehavior.off() + } + + assertThat(CookieUtils.filterCookies("theme=dark", options)).isNull() + assertThat(CookieUtils.filterSetCookie("theme=dark; Path=/", options)).isNull() + } + + @Test + fun `cookie filter disables collection in off mode`() { + assertThat( + CookieUtils.filterCookies( + "name=value", + KeyValueCollectionBehavior.off(), + emptyList(), + ) + ) + .isNull() + } + + @Test + fun `cookie deny list filters built-in configured and integration sensitive names`() { + assertThat( + CookieUtils.filterCookies( + "name=value; sessionId=secret; customerId=123; frameworkSession=456", + KeyValueCollectionBehavior.denyList("customer"), + listOf("frameworkSession"), + ) + ) + .isEqualTo( + "name=value; sessionId=[Filtered]; customerId=[Filtered]; frameworkSession=[Filtered]" + ) + } + + @Test + fun `cookie allow list only retains allowed non-sensitive values`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark; sessionId=secret; language=en", + KeyValueCollectionBehavior.allowList("theme", "session"), + emptyList(), + ) + ) + .isEqualTo("theme=dark; sessionId=[Filtered]; language=[Filtered]") + } + + @Test + fun `cookie filter preserves empty and padded base64 values`() { + assertThat( + CookieUtils.filterCookies( + "empty=; data=YWJjZA==", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("empty=; data=YWJjZA==") + } + + @Test + fun `cookie filter uses only the first equals separator`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark=contrast; token=abc=123", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark=contrast; token=[Filtered]") + } + + @Test + fun `cookie filter replaces malformed pairs without discarding valid pairs`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark; opaque; =secret; empty=; sessionId=secret", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark;[Filtered];[Filtered]; empty=; sessionId=[Filtered]") + } + + @Test + fun `cookie filter replaces comma-separated malformed cookies`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark, sessionId=secret", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `cookie filter replaces space-separated malformed cookies`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark sessionId=secret", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `cookie filter preserves valid names and values`() { + val cookies = + "plain=abc123; empty=; base64=YWJjZA==; quoted=\"dark\"; quoted-empty=\"\"; encoded=hello%2Fworld; !#\$%&'*+-.^_`|~=!#\$%&'()*+-./:<=>?@[]^_`{|}~" + + assertThat( + CookieUtils.filterCookies( + cookies, + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo(cookies) + } + + @Test + fun `cookie filter preserves trailing whitespace after a cookie pair`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark ", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark ") + } + + @Test + fun `cookie filter preserves trailing blank cookie segments`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark;", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark;") + assertThat( + CookieUtils.filterCookies( + "theme=dark; ", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark; ") + } + + @Test + fun `cookie filter replaces comma-separated malformed cookies in quoted values`() { + assertThat( + CookieUtils.filterCookies( + "theme=\"dark, sessionId=secret\"", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `cookie filter replaces space-separated malformed cookies in quoted values`() { + assertThat( + CookieUtils.filterCookies( + "theme=\"dark sessionId=secret\"", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `cookie allow list never exposes malformed pairs`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark; opaque; =secret", + KeyValueCollectionBehavior.allowList("theme", "opaque"), + emptyList(), + ) + ) + .isEqualTo("theme=dark;[Filtered];[Filtered]") + } + + @Test + fun `set cookie filter preserves attributes`() { + assertThat( + CookieUtils.filterSetCookie( + "sessionId=secret; Path=/; HttpOnly; SameSite=Lax", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("sessionId=[Filtered]; Path=/; HttpOnly; SameSite=Lax") + } + + @Test + fun `set cookie filter preserves empty and padded base64 values`() { + assertThat( + CookieUtils.filterSetCookie( + "data=YWJjZA==; Expires=Wed, 09 Jun 2021 10:18:14 GMT; Max-Age=3600; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo( + "data=YWJjZA==; Expires=Wed, 09 Jun 2021 10:18:14 GMT; Max-Age=3600; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax" + ) + assertThat( + CookieUtils.filterSetCookie( + "empty=; Path=/", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("empty=; Path=/") + } + + @Test + fun `set cookie allow list retains allowed non-sensitive value and attributes`() { + assertThat( + CookieUtils.filterSetCookie( + "theme=dark; Path=/; Secure", + KeyValueCollectionBehavior.allowList("theme"), + ) + ) + .isEqualTo("theme=dark; Path=/; Secure") + } + + @Test + fun `set cookie filter replaces malformed cookie pair and discards attributes`() { + assertThat( + CookieUtils.filterSetCookie( + "opaque; Path=/; HttpOnly", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("[Filtered]") + assertThat( + CookieUtils.filterSetCookie( + "=secret; Path=/; HttpOnly", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `set cookie allow list never exposes malformed cookie pair`() { + assertThat( + CookieUtils.filterSetCookie( + "opaque; Path=/; HttpOnly", + KeyValueCollectionBehavior.allowList("opaque"), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `set cookie filter disables collection in off mode`() { + assertThat( + CookieUtils.filterSetCookie( + "theme=dark; Path=/", + KeyValueCollectionBehavior.off(), + ) + ) + .isNull() + } + + @Test + fun `cookie header filter processes every header value`() { + assertThat( + CookieUtils.filterCookiesFromHeader( + listOf("theme=dark; SID=secret", "language=en"), + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .containsExactly("theme=dark; SID=[Filtered]", "language=en") + .inOrder() + } + + @Test + fun `cookie header filter skips null header values`() { + assertThat( + CookieUtils.filterCookiesFromHeader( + java.util.Arrays.asList("theme=dark", null), + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .containsExactly("theme=dark") + } + + @Test + fun `null enumeration returns null when filtering security cookies from headers`() { + val enumeration: Enumeration? = null + val headers = CookieUtils.filterOutSecurityCookiesFromHeader(enumeration, "Cookie", emptyList()) + + assertNull(headers) + } + + @Test + fun `null list returns null when filtering security cookies from headers`() { + val list: List? = null + val headers = CookieUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", emptyList()) + + assertNull(headers) + } + + @Test + fun `enumeration works when filtering security cookies from headers`() { + val enumeration: Enumeration? = + StringTokenizer( + "Cookie_2=value2; Cookie_3=value3; JSESSIONID=123456789; mysessioncookiename=1F54D793F432FEE4CFC6A3FAED6D062F|Cookie_1=value1; SID=987654312", + "|", + ) + as Enumeration + val headers = + CookieUtils.filterOutSecurityCookiesFromHeader( + enumeration, + "Cookie", + listOf("mysessioncookiename"), + ) + + assertNotNull(headers) + assertEquals(2, headers.size) + assertEquals( + "Cookie_2=value2; Cookie_3=value3; JSESSIONID=[Filtered]; mysessioncookiename=[Filtered]", + headers!![0], + ) + assertEquals("Cookie_1=value1; SID=[Filtered]", headers!![1]) + } + + @Test + fun `list works when filtering security cookies from headers`() { + val list: List? = + listOf( + "Cookie_2=value2; Cookie_3=value3; JSESSIONID=123456789; mysessioncookiename=1F54D793F432FEE4CFC6A3FAED6D062F", + "Cookie_1=value1; SID=987654312", + ) + val headers = + CookieUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", listOf("mysessioncookiename")) + + assertNotNull(headers) + assertEquals(2, headers.size) + assertEquals( + "Cookie_2=value2; Cookie_3=value3; JSESSIONID=[Filtered]; mysessioncookiename=[Filtered]", + headers!![0], + ) + assertEquals("Cookie_1=value1; SID=[Filtered]", headers!![1]) + } + + @Test + fun `filtering security cookies from header works for corrupted string`() { + val list: List? = listOf("Cookie_1=value1;; SID=; JSESSIONID; =") + val headers = + CookieUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", listOf("mysessioncookiename")) + + assertNotNull(headers) + assertEquals(1, headers.size) + assertEquals("Cookie_1=value1;; SID=[Filtered]; JSESSIONID=[Filtered]; =", headers!![0]) + } + + @Test + fun `filtering security cookies from header works for null string`() { + val list: List? = listOf(null) + val headers = + CookieUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", listOf("mysessioncookiename")) + + assertNotNull(headers) + assertEquals(1, headers.size) + assertEquals(null, headers!![0]) + } +} diff --git a/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt b/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt new file mode 100644 index 00000000000..d5cc325baef --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt @@ -0,0 +1,100 @@ +package io.sentry.util + +import com.google.common.truth.Truth.assertThat +import io.sentry.JsonObjectReader +import io.sentry.SentryOptions +import java.io.StringReader +import kotlin.test.Test + +class GraphqlUtilsTest { + @Test + fun `filters document from a GraphQL request body`() { + val options = SentryOptions().also { it.dataCollection.graphql.setDocument(false) } + + val result = GraphqlUtils.filterRequestBody(REQUEST_BODY, options) + + JsonObjectReader(StringReader(result)).use { reader -> + @Suppress("UNCHECKED_CAST") val body = reader.nextObjectOrNull() as Map + assertThat(body).containsEntry("operationName", "GetUser") + assertThat(body).containsEntry("variables", mapOf("id" to "123")) + assertThat(body).doesNotContainKey("query") + } + } + + @Test + fun `filters variables from a GraphQL request body`() { + val options = SentryOptions().also { it.dataCollection.graphql.setVariables(false) } + + val result = GraphqlUtils.filterRequestBody(REQUEST_BODY, options) + + JsonObjectReader(StringReader(result)).use { reader -> + @Suppress("UNCHECKED_CAST") val body = reader.nextObjectOrNull() as Map + assertThat(body).containsEntry("operationName", "GetUser") + assertThat(body).containsEntry("query", "query { viewer { name } }") + assertThat(body).doesNotContainKey("variables") + } + } + + @Test + fun `filters documents from a batched GraphQL request body`() { + val options = SentryOptions().also { it.dataCollection.graphql.setDocument(false) } + + val result = GraphqlUtils.filterRequestBody(BATCH_REQUEST_BODY, options) + + assertThat(result).isNotNull() + JsonObjectReader(StringReader(result)).use { reader -> + @Suppress("UNCHECKED_CAST") val body = reader.nextObjectOrNull() as List> + assertThat(body).hasSize(2) + assertThat(body[0]).containsEntry("operationName", "GetUser") + assertThat(body[0]).containsEntry("variables", mapOf("id" to "123")) + assertThat(body[0]).doesNotContainKey("query") + assertThat(body[1]).containsEntry("operationName", "GetTeam") + assertThat(body[1]).containsEntry("variables", mapOf("slug" to "sdk")) + assertThat(body[1]).doesNotContainKey("query") + } + } + + @Test + fun `filters variables from a batched GraphQL request body`() { + val options = SentryOptions().also { it.dataCollection.graphql.setVariables(false) } + + val result = GraphqlUtils.filterRequestBody(BATCH_REQUEST_BODY, options) + + assertThat(result).isNotNull() + JsonObjectReader(StringReader(result)).use { reader -> + @Suppress("UNCHECKED_CAST") val body = reader.nextObjectOrNull() as List> + assertThat(body).hasSize(2) + assertThat(body[0]).containsEntry("operationName", "GetUser") + assertThat(body[0]).containsEntry("query", "query { viewer { name } }") + assertThat(body[0]).doesNotContainKey("variables") + assertThat(body[1]).containsEntry("operationName", "GetTeam") + assertThat(body[1]).containsEntry("query", "query { team { name } }") + assertThat(body[1]).doesNotContainKey("variables") + } + } + + @Test + fun `returns null for a batched GraphQL request body containing a non-object entry`() { + val options = SentryOptions().also { it.dataCollection.graphql.setDocument(false) } + + val result = GraphqlUtils.filterRequestBody("""[$REQUEST_BODY,"unexpected"]""", options) + + assertThat(result).isNull() + } + + @Test + fun `returns null for a GraphQL request body containing a malformed unicode escape`() { + val options = SentryOptions().also { it.dataCollection.graphql.setDocument(false) } + + val result = GraphqlUtils.filterRequestBody("""{"query":"\u12G4"}""", options) + + assertThat(result).isNull() + } + + private companion object { + const val REQUEST_BODY = + """{"operationName":"GetUser","variables":{"id":"123"},"query":"query { viewer { name } }"}""" + const val BATCH_REQUEST_BODY = + """[{"operationName":"GetUser","variables":{"id":"123"},"query":"query { viewer { name } }"},{"operationName":"GetTeam","variables":{"slug":"sdk"},"query":"query { team { name } }"}]""" + } +} diff --git a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt index 6d7815888e5..624f94a2ef0 100644 --- a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt @@ -1,91 +1,116 @@ package io.sentry.util -import java.util.Enumeration -import java.util.StringTokenizer +import com.google.common.truth.Truth.assertThat +import io.sentry.KeyValueCollectionBehavior import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull class HttpUtilsTest { @Test - fun `null enumeration returns null when filtering security cookies from headers`() { - val enumeration: Enumeration? = null - val headers = HttpUtils.filterOutSecurityCookiesFromHeader(enumeration, "Cookie", emptyList()) - - assertNull(headers) + fun `query parameter filter disables collection in off mode`() { + assertThat(HttpUtils.filterQueryParams("name=value", KeyValueCollectionBehavior.off())).isNull() } @Test - fun `null list returns null when filtering security cookies from headers`() { - val list: List? = null - val headers = HttpUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", emptyList()) - - assertNull(headers) + fun `query parameter deny list filters built-in sensitive and configured terms`() { + assertThat( + HttpUtils.filterQueryParams( + "name=value&access_token=secret&customerId=123", + KeyValueCollectionBehavior.denyList("customer"), + ) + ) + .isEqualTo("name=value&access_token=[Filtered]&customerId=[Filtered]") } @Test - fun `enumeration works when filtering security cookies from headers`() { - val enumeration: Enumeration? = - StringTokenizer( - "Cookie_2=value2; Cookie_3=value3; JSESSIONID=123456789; mysessioncookiename=1F54D793F432FEE4CFC6A3FAED6D062F|Cookie_1=value1; SID=987654312", - "|", + fun `query parameter allow list only retains allowed non-sensitive values`() { + assertThat( + HttpUtils.filterQueryParams( + "name=value&access_token=secret&customerId=123", + KeyValueCollectionBehavior.allowList("name", "access_token"), + ) ) - as Enumeration - val headers = - HttpUtils.filterOutSecurityCookiesFromHeader( - enumeration, - "Cookie", - listOf("mysessioncookiename"), + .isEqualTo("name=value&access_token=[Filtered]&customerId=[Filtered]") + } + + @Test + fun `query parameter filter matches decoded names and preserves encoding`() { + assertThat( + HttpUtils.filterQueryParams( + "access%5Ftoken=secret&display%20name=Jane+Doe", + KeyValueCollectionBehavior.denyList(), + ) ) + .isEqualTo("access%5Ftoken=[Filtered]&display%20name=Jane+Doe") + } - assertNotNull(headers) - assertEquals(2, headers.size) - assertEquals( - "Cookie_2=value2; Cookie_3=value3; JSESSIONID=[Filtered]; mysessioncookiename=[Filtered]", - headers!![0], - ) - assertEquals("Cookie_1=value1; SID=[Filtered]", headers!![1]) + @Test + fun `query parameter filter preserves empty parameters and values`() { + assertThat( + HttpUtils.filterQueryParams( + "name=&flag&&token", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("name=&flag&&token=[Filtered]") } @Test - fun `list works when filtering security cookies from headers`() { - val list: List? = - listOf( - "Cookie_2=value2; Cookie_3=value3; JSESSIONID=123456789; mysessioncookiename=1F54D793F432FEE4CFC6A3FAED6D062F", - "Cookie_1=value1; SID=987654312", + fun `header filter disables collection in off mode`() { + val filtered = + HttpUtils.filterHeaders( + mapOf("content-type" to "application/json"), + KeyValueCollectionBehavior.off(), ) - val headers = - HttpUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", listOf("mysessioncookiename")) - assertNotNull(headers) - assertEquals(2, headers.size) - assertEquals( - "Cookie_2=value2; Cookie_3=value3; JSESSIONID=[Filtered]; mysessioncookiename=[Filtered]", - headers!![0], - ) - assertEquals("Cookie_1=value1; SID=[Filtered]", headers!![1]) + assertThat(filtered).isEmpty() } @Test - fun `filtering security cookies from header works for corrupted string`() { - val list: List? = listOf("Cookie_1=value1;; SID=; JSESSIONID; =") - val headers = - HttpUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", listOf("mysessioncookiename")) + fun `header deny list filters built-in sensitive and configured terms`() { + val filtered = + HttpUtils.filterHeaders( + mapOf( + "content-type" to "application/json", + "authorization" to "Bearer token", + "x-customer" to "customer value", + "Cookie" to "name=value", + ), + KeyValueCollectionBehavior.denyList("customer"), + ) - assertNotNull(headers) - assertEquals(1, headers.size) - assertEquals("Cookie_1=value1;; SID=[Filtered]; JSESSIONID=[Filtered]; =", headers!![0]) + assertThat(filtered) + .containsExactly( + "content-type", + "application/json", + "authorization", + "[Filtered]", + "x-customer", + "[Filtered]", + "Cookie", + "[Filtered]", + ) } @Test - fun `filtering security cookies from header works for null string`() { - val list: List? = listOf(null) - val headers = - HttpUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", listOf("mysessioncookiename")) + fun `header allow list only retains allowed non-sensitive values`() { + val filtered = + HttpUtils.filterHeaders( + mapOf( + "content-type" to "application/json", + "authorization" to "Bearer token", + "x-customer" to "customer value", + ), + KeyValueCollectionBehavior.allowList("content", "authorization"), + ) - assertNotNull(headers) - assertEquals(1, headers.size) - assertEquals(null, headers!![0]) + assertThat(filtered) + .containsExactly( + "content-type", + "application/json", + "authorization", + "[Filtered]", + "x-customer", + "[Filtered]", + ) } } diff --git a/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt b/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt index a971fbf7d71..f7bdbcbcfb1 100644 --- a/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt @@ -1,10 +1,76 @@ package io.sentry.util +import com.google.common.truth.Truth.assertThat +import io.sentry.Breadcrumb +import io.sentry.ISpan +import io.sentry.KeyValueCollectionBehavior +import io.sentry.SentryOptions +import io.sentry.SpanDataConvention +import io.sentry.protocol.Request import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify class UrlUtilsTest { + @Test + fun `resolver aware helpers preserve legacy query values`() { + val resolver = SentryOptions().dataCollectionResolver + val details = UrlUtils.parse("https://example.com?token=secret", resolver) + val request = Request() + + details.applyToRequest(request) + + assertThat(request.queryString).isEqualTo("token=secret") + } + + @Test + fun `resolver aware helpers filter request span and breadcrumb queries`() { + val options = SentryOptions().also { it.dataCollection.setUserInfo(false) } + val details = + UrlUtils.parse( + "https://example.com?name=value&token=secret", + options.dataCollectionResolver, + ) + val request = Request() + val span = mock() + val breadcrumb = + Breadcrumb.http( + "https://example.com?name=value&token=secret", + "GET", + null, + options.dataCollectionResolver, + ) + + details.applyToRequest(request) + details.applyToSpan(span) + + assertThat(request.queryString).isEqualTo("name=value&token=[Filtered]") + verify(span).setData(SpanDataConvention.HTTP_QUERY_KEY, "name=value&token=[Filtered]") + assertThat(breadcrumb.getData("http.query")).isEqualTo("name=value&token=[Filtered]") + } + + @Test + fun `resolver aware helpers remove query values in off mode`() { + val options = + SentryOptions().also { it.dataCollection.urlQueryParams = KeyValueCollectionBehavior.off() } + val details = UrlUtils.parse("https://example.com?name=value", options.dataCollectionResolver) + val request = Request() + val breadcrumb = + Breadcrumb.http( + "https://example.com?name=value", + "GET", + null, + options.dataCollectionResolver, + ) + + details.applyToRequest(request) + + assertThat(request.queryString).isNull() + assertThat(breadcrumb.getData("http.query")).isNull() + } + @Test fun `returns null for null`() { assertNull(UrlUtils.parseNullable(null)) @@ -291,10 +357,36 @@ class UrlUtilsTest { } @Test - fun `does not extract details from websockets uri`() { - val urlDetails = UrlUtils.parse("wss://example.com/socket") - assertNull(urlDetails.url) - assertNull(urlDetails.query) - assertNull(urlDetails.fragment) + fun `extracts details from websocket uri`() { + val urlDetails = UrlUtils.parse("ws://example.com/socket?channel=updates#top") + + assertThat(urlDetails.url).isEqualTo("ws://example.com/socket") + assertThat(urlDetails.query).isEqualTo("channel=updates") + assertThat(urlDetails.fragment).isEqualTo("top") + } + + @Test + fun `filters query parameters from secure websocket uri`() { + val options = SentryOptions().also { it.dataCollection.setUserInfo(false) } + val urlDetails = + UrlUtils.parse( + "wss://example.com/socket?channel=updates&token=secret", + options.dataCollectionResolver, + ) + + assertThat(urlDetails.url).isEqualTo("wss://example.com/socket") + assertThat(urlDetails.query).isEqualTo("channel=updates&token=[Filtered]") + assertThat(urlDetails.fragment).isNull() + } + + @Test + fun `does not extract details from websocket uri without authority`() { + listOf("ws:example.com/socket", "wss:///socket").forEach { url -> + val urlDetails = UrlUtils.parse(url) + + assertThat(urlDetails.url).isNull() + assertThat(urlDetails.query).isNull() + assertThat(urlDetails.fragment).isNull() + } } }