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..0f322481e69 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,7 @@ import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Mechanism import io.sentry.protocol.Request import io.sentry.protocol.Response +import io.sentry.util.GraphqlUtils import io.sentry.util.HttpUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.Platform @@ -159,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" @@ -174,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()) } } @@ -229,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) @@ -261,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) { @@ -318,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 its not a target match if (!PropagationTargetsUtils.contain(failedRequestTargets, urlDetails.urlOrFallback)) { @@ -356,22 +395,24 @@ constructor( cookies = if (scopes.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null 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() + } } } } @@ -385,13 +426,15 @@ constructor( } else { null } - headers = getHeaders(response.headers) + 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..d2294f45bb0 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 @@ -72,6 +74,7 @@ class SentryApollo3InterceptorClientErrors { responseBody: String = responseBodyOk, sendDefaultPii: Boolean = false, socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + configureOptions: SentryOptions.() -> Unit = {}, ): ApolloClient { SentryIntegrationPackageStorage.getInstance().clearStorage() @@ -83,6 +86,7 @@ class SentryApollo3InterceptorClientErrors { dsn = "https://key@sentry.io/proj" sdkVersion = SdkVersion("test", "1.2.3") isSendDefaultPii = sendDefaultPii + configureOptions() } ) } @@ -266,6 +270,113 @@ 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 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) @@ -304,6 +415,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) 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..0a16c669914 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,7 @@ import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Mechanism import io.sentry.protocol.Request import io.sentry.protocol.Response +import io.sentry.util.GraphqlUtils import io.sentry.util.HttpUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.Platform @@ -158,7 +159,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 +174,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 +231,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 +269,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 +356,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)) { @@ -355,22 +394,24 @@ constructor( cookies = if (scopes.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null 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() + } } } } @@ -384,13 +425,15 @@ constructor( } else { null } - headers = getHeaders(response.headers) + 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..a5e9c01b4a0 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 @@ -86,6 +88,7 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( responseBody: String = responseBodyOk, sendDefaultPii: Boolean = false, socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + configureOptions: SentryOptions.() -> Unit = {}, ): ApolloClient { SentryIntegrationPackageStorage.getInstance().clearStorage() @@ -97,6 +100,7 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( dsn = "https://key@sentry.io/proj" sdkVersion = SdkVersion("test", "1.2.3") isSendDefaultPii = sendDefaultPii + configureOptions() } ) } @@ -280,6 +284,110 @@ 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 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) @@ -317,6 +425,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) 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/SentryKtorClientUtils.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt index b56d3042de4..793911a8f49 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 @@ -25,7 +25,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 = @@ -40,7 +40,7 @@ internal object SentryKtorClientUtils { urlDetails.applyToRequest(this) cookies = if (scopes.options.isSendDefaultPii) request.headers["Cookie"] else null method = request.method.value - headers = getHeaders(scopes, request.headers) + headers = getRequestHeaders(scopes, request.headers) bodySize = request.content.contentLength } @@ -48,7 +48,7 @@ internal object SentryKtorClientUtils { 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) + headers = getResponseHeaders(scopes, response.headers) statusCode = response.status.value try { bodySize = response.bodyAsBytes().size.toLong() @@ -67,6 +67,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 +116,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..eab2562fba4 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 @@ -255,6 +256,109 @@ class SentryKtorClientPluginTest { verify(fixture.scopes, never()).captureEvent(any(), 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 = 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..ce8759e5715 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt @@ -21,7 +21,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 = @@ -40,7 +40,7 @@ internal object SentryOkHttpUtils { // Cookie is only sent if isSendDefaultPii is enabled cookies = if (scopes.options.isSendDefaultPii) request.headers["Cookie"] else null method = request.method - headers = getHeaders(scopes, request.headers) + headers = getRequestHeaders(scopes, request.headers) request.body?.contentLength().ifHasValidLength { bodySize = it } } @@ -49,7 +49,7 @@ internal object SentryOkHttpUtils { 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) + headers = getResponseHeaders(scopes, response.headers) statusCode = response.code response.body?.contentLength().ifHasValidLength { bodySize = it } @@ -67,6 +67,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/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index 9f7d8bc18fb..7b49105dc13 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -504,6 +504,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..d29e8df8260 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) @@ -121,6 +124,74 @@ class SentryOkHttpUtilsTest { ) } + @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-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/SentryRequestResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java index aba0ae808d1..0f1ef3fcfff 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 @@ -37,9 +37,13 @@ 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)); @@ -60,8 +64,8 @@ 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( @@ -69,6 +73,10 @@ Map resolveHeadersMap( 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/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 6726302a83e..ae2446f121f 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 @@ -15,6 +15,7 @@ import io.sentry.util.Objects; import io.sentry.util.SpanUtils; import io.sentry.util.TracingUtils; +import io.sentry.util.UrlUtils; import java.util.Locale; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -45,9 +46,19 @@ 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(), scopes.getOptions().getDataCollectionResolver()); final @NotNull String method = request.method().name(); - span.setDescription(method + " " + request.url()); + span.setDescription( + method + + " " + + (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + ? urlDetails.getUrlOrFallback() + : request.url())); 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); @@ -113,7 +124,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..a355d379a83 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 @@ -32,7 +32,8 @@ 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())); @@ -50,9 +51,9 @@ 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, @@ -61,6 +62,10 @@ Map resolveHeadersMap(final HttpHeaders request) { 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/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/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-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..81f053f32a1 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 @@ -37,9 +37,13 @@ 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)); @@ -60,8 +64,8 @@ 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( @@ -69,6 +73,10 @@ Map resolveHeadersMap( 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/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 1189532c0c4..51f68afd3f8 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 @@ -15,6 +15,7 @@ import io.sentry.util.Objects; import io.sentry.util.SpanUtils; import io.sentry.util.TracingUtils; +import io.sentry.util.UrlUtils; import java.util.Locale; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -45,9 +46,19 @@ 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(), scopes.getOptions().getDataCollectionResolver()); final @NotNull String method = request.method().name(); - span.setDescription(method + " " + request.url()); + span.setDescription( + method + + " " + + (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + ? urlDetails.getUrlOrFallback() + : request.url())); 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); @@ -113,7 +124,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..8a5cf168aa5 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 @@ -32,7 +32,8 @@ 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())); @@ -50,9 +51,9 @@ 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, @@ -61,6 +62,10 @@ Map resolveHeadersMap(final HttpHeaders request) { 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/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/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/SentryRequestResolver.java b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java index 56294fda083..b33f51e41a2 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java @@ -37,9 +37,13 @@ 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)); @@ -60,8 +64,8 @@ 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( @@ -69,6 +73,10 @@ Map resolveHeadersMap( 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/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..7c6ecfb5ad0 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 @@ -32,7 +32,8 @@ 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())); @@ -50,9 +51,9 @@ 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, @@ -61,6 +62,10 @@ Map resolveHeadersMap(final HttpHeaders request) { 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/SentrySpringFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt index eb145bcd8a1..b33ad7731d5 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,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/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 00183bc9b30..5269609a91f 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; @@ -383,6 +384,59 @@ 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 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 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; @@ -635,6 +689,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 @@ -1381,6 +1444,24 @@ public abstract interface class io/sentry/JsonUnknown { public abstract fun setUnknown (Ljava/util/Map;)V } +public final class io/sentry/KeyValueCollectionBehavior { + 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 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 @@ -3636,6 +3717,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; @@ -3784,6 +3867,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 @@ -7685,6 +7769,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; @@ -7717,9 +7805,11 @@ 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 filterHeaders (Ljava/util/Map;Lio/sentry/KeyValueCollectionBehavior;)Ljava/util/Map; 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 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 @@ -7978,7 +8068,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..0df43e1bd0a --- /dev/null +++ b/sentry/src/main/java/io/sentry/DataCollection.java @@ -0,0 +1,147 @@ +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); + } + + 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 + 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..c1fb4f2be97 --- /dev/null +++ b/sentry/src/main/java/io/sentry/DataCollectionResolver.java @@ -0,0 +1,139 @@ +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 static final @NotNull KeyValueCollectionBehavior OFF = KeyValueCollectionBehavior.off(); + private static final @NotNull KeyValueCollectionBehavior EMPTY_DENY_LIST = + KeyValueCollectionBehavior.denyList(); + + 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 EMPTY_DENY_LIST; + } + return options.isSendDefaultPii() ? EMPTY_DENY_LIST : 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 : EMPTY_DENY_LIST; + } + + 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/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..d9daeb9bc02 --- /dev/null +++ b/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java @@ -0,0 +1,76 @@ +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 final @NotNull Mode mode; + private final @NotNull List terms; + + private KeyValueCollectionBehavior(final @NotNull Mode mode, final @NotNull List terms) { + this.mode = mode; + this.terms = Collections.unmodifiableList(new ArrayList<>(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 @NotNull List getTerms() { + return 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/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 3c55f5e1cfa..e312fe94c47 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -338,6 +338,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; @@ -1697,6 +1702,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 * 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..06893e6f5a8 --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/GraphqlUtils.java @@ -0,0 +1,78 @@ +package io.sentry.util; + +import io.sentry.DataCollectionResolver; +import io.sentry.JsonObjectReader; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +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 (Throwable 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..936571f4ba7 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -3,12 +3,16 @@ import static io.sentry.util.UrlUtils.SENSITIVE_DATA_SUBSTITUTE; import io.sentry.HttpStatusCodeRange; +import io.sentry.KeyValueCollectionBehavior; +import java.net.URLDecoder; import java.util.ArrayList; 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; @@ -33,6 +37,26 @@ public final class HttpUtils { "X-CSRFTOKEN", "X-XSRF-TOKEN"); + private static final List SENSITIVE_DATA_KEYS = + Arrays.asList( + "auth", + "token", + "secret", + "password", + "passwd", + "pwd", + "key", + "jwt", + "bearer", + "sso", + "saml", + "csrf", + "xsrf", + "credentials", + "session", + "sid", + "identity"); + private static final List SECURITY_COOKIES = Arrays.asList( "JSESSIONID", @@ -53,6 +77,89 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { return SENSITIVE_HEADERS.contains(header.toUpperCase(Locale.ROOT)); } + public static @Nullable String filterQueryParams( + final @Nullable String query, final @NotNull KeyValueCollectionBehavior behavior) { + if (query == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + 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('&'); + } + + 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(); + } + + 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; + } + + 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; + } + + private static @NotNull String decodeQueryParamName(final @NotNull String name) { + try { + return URLDecoder.decode(name, "UTF-8"); + } catch (Throwable ignored) { + return name; + } + } + + private 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; + } + public static @Nullable List filterOutSecurityCookiesFromHeader( final @Nullable Enumeration headers, final @Nullable String headerName, diff --git a/sentry/src/main/java/io/sentry/util/UrlUtils.java b/sentry/src/main/java/io/sentry/util/UrlUtils.java index 6c70cea0495..f8fdcd273a6 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,6 +47,13 @@ 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) { try { uri.toURL(); 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..c67c326e1db --- /dev/null +++ b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt @@ -0,0 +1,357 @@ +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 `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/KeyValueCollectionBehaviorTest.kt b/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt new file mode 100644 index 00000000000..7e014eec504 --- /dev/null +++ b/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt @@ -0,0 +1,49 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test + +class KeyValueCollectionBehaviorTest { + @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/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 9402c6fee9b..d5c7e6f3c7e 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,67 @@ 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 `when options is initialized, logger is not null`() { assertNotNull(SentryOptions().logger) 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..ea72368e74d --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt @@ -0,0 +1,91 @@ +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() + } + + 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..1da3b82b516 100644 --- a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt @@ -1,5 +1,7 @@ package io.sentry.util +import com.google.common.truth.Truth.assertThat +import io.sentry.KeyValueCollectionBehavior import java.util.Enumeration import java.util.StringTokenizer import kotlin.test.Test @@ -8,6 +10,115 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull class HttpUtilsTest { + @Test + fun `query parameter filter disables collection in off mode`() { + assertThat(HttpUtils.filterQueryParams("name=value", KeyValueCollectionBehavior.off())).isNull() + } + + @Test + 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 `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"), + ) + ) + .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") + } + + @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 `header filter disables collection in off mode`() { + val filtered = + HttpUtils.filterHeaders( + mapOf("content-type" to "application/json"), + KeyValueCollectionBehavior.off(), + ) + + assertThat(filtered).isEmpty() + } + + @Test + 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"), + ) + + assertThat(filtered) + .containsExactly( + "content-type", + "application/json", + "authorization", + "[Filtered]", + "x-customer", + "[Filtered]", + "Cookie", + "[Filtered]", + ) + } + + @Test + 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"), + ) + + assertThat(filtered) + .containsExactly( + "content-type", + "application/json", + "authorization", + "[Filtered]", + "x-customer", + "[Filtered]", + ) + } + @Test fun `null enumeration returns null when filtering security cookies from headers`() { val enumeration: Enumeration? = null diff --git a/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt b/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt index a971fbf7d71..18c9444917c 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))