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 0f322481e6..cd4724155f 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 @@ -270,6 +270,28 @@ constructor( private fun getHeader(key: String, headers: List): String? = headers.firstOrNull { it.name.equals(key, true) }?.value + private fun getRequestCookies(headers: List): String? { + val cookies = getHeader("Cookie", headers) + return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterCookies(cookies, scopes.options.dataCollectionResolver.cookies, null) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + } + + private fun getResponseCookies(headers: List): String? { + val cookies = getHeader("Set-Cookie", headers) + return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + } + private fun getRequestHeaders(headers: List): MutableMap? { if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { val requestHeaders = mutableMapOf() @@ -391,9 +413,7 @@ constructor( val sentryRequest = Request().apply { urlDetails.applyToRequest(this) - // Cookie is only sent if isSendDefaultPii is enabled - cookies = - if (scopes.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null + cookies = getRequestCookies(request.headers) method = request.method.name headers = getRequestHeaders(request.headers) apiTarget = "graphql" @@ -419,13 +439,7 @@ constructor( val sentryResponse = Response().apply { - // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII - cookies = - if (scopes.options.isSendDefaultPii) { - getHeader("Set-Cookie", response.headers) - } else { - null - } + cookies = getResponseCookies(response.headers) headers = getResponseHeaders(response.headers) statusCode = response.statusCode 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 d2294f45bb..315f15a97b 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 @@ -73,6 +73,7 @@ class SentryApollo3InterceptorClientErrors { httpStatusCode: Int = 200, responseBody: String = responseBodyOk, sendDefaultPii: Boolean = false, + includeCookies: Boolean = sendDefaultPii, socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, configureOptions: SentryOptions.() -> Unit = {}, ): ApolloClient { @@ -98,8 +99,8 @@ class SentryApollo3InterceptorClientErrors { .setSocketPolicy(socketPolicy) .setResponseCode(httpStatusCode) - if (sendDefaultPii) { - response.addHeader("Set-Cookie", "Test") + if (includeCookies) { + response.addHeader("Set-Cookie", "theme=dark; Path=/") } server.enqueue(response) @@ -112,8 +113,8 @@ class SentryApollo3InterceptorClientErrors { captureFailedRequests = captureFailedRequests, failedRequestTargets = failedRequestTargets, ) - if (sendDefaultPii) { - builder.addHttpHeader("Cookie", "Test") + if (includeCookies) { + builder.addHttpHeader("Cookie", "theme=dark; sessionId=secret") } return builder.build() @@ -362,6 +363,46 @@ class SentryApollo3InterceptorClientErrors { ) } + @Test + fun `data collection filters cookies`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk, includeCookies = true) { + dataCollection.cookies = KeyValueCollectionBehavior.denyList("theme") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("theme=[Filtered]; sessionId=[Filtered]", it.request!!.cookies) + assertEquals("theme=[Filtered]; Path=/", it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection can disable cookies`() { + val sut = + fixture.getSut( + responseBody = fixture.responseBodyNotOk, + sendDefaultPii = true, + includeCookies = true, + ) { + dataCollection.cookies = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertNull(it.request!!.cookies) + assertNull(it.contexts.response!!.cookies) + }, + any(), + ) + } + @Test fun `data collection can disable request headers`() { val sut = @@ -387,7 +428,7 @@ class SentryApollo3InterceptorClientErrors { check { val request = it.request!! - assertEquals("Test", request.cookies) + assertEquals("theme=dark; sessionId=secret", request.cookies) assertNotNull(request.headers) assertEquals("LaunchDetails", request.headers?.get("X-APOLLO-OPERATION-NAME")) }, @@ -477,7 +518,7 @@ class SentryApollo3InterceptorClientErrors { check { val response = it.contexts.response!! - assertEquals("Test", response.cookies) + assertEquals("theme=dark; Path=/", response.cookies) assertNotNull(response.headers) assertEquals(200, response.headers?.get("Content-Length")?.toInt()) }, diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index 0a16c66991..cab278f5b3 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 @@ -269,6 +269,28 @@ constructor( private fun getHeader(key: String, headers: List): String? = headers.firstOrNull { it.name.equals(key, true) }?.value + private fun getRequestCookies(headers: List): String? { + val cookies = getHeader("Cookie", headers) + return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterCookies(cookies, scopes.options.dataCollectionResolver.cookies, null) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + } + + private fun getResponseCookies(headers: List): String? { + val cookies = getHeader("Set-Cookie", headers) + return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + } + private fun getRequestHeaders(headers: List): MutableMap? { if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { val requestHeaders = mutableMapOf() @@ -390,9 +412,7 @@ constructor( val sentryRequest = Request().apply { urlDetails.applyToRequest(this) - // Cookie is only sent if isSendDefaultPii is enabled - cookies = - if (scopes.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null + cookies = getRequestCookies(request.headers) method = request.method.name headers = getRequestHeaders(request.headers) apiTarget = "graphql" @@ -418,13 +438,7 @@ constructor( val sentryResponse = Response().apply { - // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII - cookies = - if (scopes.options.isSendDefaultPii) { - getHeader("Set-Cookie", response.headers) - } else { - null - } + cookies = getResponseCookies(response.headers) headers = getResponseHeaders(response.headers) statusCode = response.statusCode 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 21bb0dc1b7..9cd2468d16 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 @@ -87,6 +87,7 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( httpStatusCode: Int = 200, responseBody: String = responseBodyOk, sendDefaultPii: Boolean = false, + includeCookies: Boolean = sendDefaultPii, socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, configureOptions: SentryOptions.() -> Unit = {}, ): ApolloClient { @@ -112,8 +113,8 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( .setSocketPolicy(socketPolicy) .setResponseCode(httpStatusCode) - if (sendDefaultPii) { - response.addHeader("Set-Cookie", "Test") + if (includeCookies) { + response.addHeader("Set-Cookie", "theme=dark; Path=/") } server.enqueue(response) @@ -126,8 +127,8 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( captureFailedRequests = captureFailedRequests, failedRequestTargets = failedRequestTargets, ) - if (sendDefaultPii) { - builder.addHttpHeader("Cookie", "Test") + if (includeCookies) { + builder.addHttpHeader("Cookie", "theme=dark; sessionId=secret") } return builder.build() @@ -356,6 +357,46 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( ) } + @Test + fun `data collection filters cookies`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk, includeCookies = true) { + dataCollection.cookies = KeyValueCollectionBehavior.denyList("theme") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("theme=[Filtered]; sessionId=[Filtered]", it.request!!.cookies) + assertEquals("theme=[Filtered]; Path=/", it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection can disable cookies`() { + val sut = + fixture.getSut( + responseBody = fixture.responseBodyNotOk, + sendDefaultPii = true, + includeCookies = true, + ) { + dataCollection.cookies = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertNull(it.request!!.cookies) + assertNull(it.contexts.response!!.cookies) + }, + any(), + ) + } + @Test fun `data collection can disable request headers`() { val sut = @@ -381,7 +422,7 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( check { val request = it.request!! - assertEquals("Test", request.cookies) + assertEquals("theme=dark; sessionId=secret", request.cookies) assertNotNull(request.headers) }, any(), @@ -453,7 +494,7 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( check { val response = it.contexts.response!! - assertEquals("Test", response.cookies) + assertEquals("theme=dark; Path=/", response.cookies) assertNotNull(response.headers) assertEquals(200, response.headers?.get("Content-Length")?.toInt()) }, diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt index 793911a8f4..40af65b849 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 @@ -36,9 +36,8 @@ internal object SentryKtorClientUtils { val sentryRequest = io.sentry.protocol.Request().apply { - // Cookie is only sent if isSendDefaultPii is enabled urlDetails.applyToRequest(this) - cookies = if (scopes.options.isSendDefaultPii) request.headers["Cookie"] else null + cookies = getRequestCookies(scopes, request.headers["Cookie"]) method = request.method.value headers = getRequestHeaders(scopes, request.headers) bodySize = request.content.contentLength @@ -46,8 +45,7 @@ internal object SentryKtorClientUtils { val sentryResponse = io.sentry.protocol.Response().apply { - // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII - cookies = if (scopes.options.isSendDefaultPii) response.headers["Set-Cookie"] else null + cookies = getResponseCookies(scopes, response.headers["Set-Cookie"]) headers = getResponseHeaders(scopes, response.headers) statusCode = response.status.value try { @@ -67,6 +65,28 @@ internal object SentryKtorClientUtils { scopes.captureEvent(event, hint) } + private fun getRequestCookies(scopes: IScopes, cookies: String?): String? = + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterCookies( + cookies, + scopes.options.dataCollectionResolver.cookies, + null, + ) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + + private fun getResponseCookies(scopes: IScopes, cookies: String?): String? = + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + private fun getRequestHeaders(scopes: IScopes, headers: Headers): MutableMap? { if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { val requestHeaders = 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 eab2562fba..8456f5658e 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 @@ -104,6 +104,7 @@ class SentryKtorClientPluginTest { MockResponse() .setBody(responseBody) .addHeader("myResponseHeader", "myValue") + .addHeader("Set-Cookie", "theme=dark; Path=/") .setSocketPolicy(socketPolicy) .setResponseCode(httpStatusCode) ) @@ -256,6 +257,60 @@ class SentryKtorClientPluginTest { verify(fixture.scopes, never()).captureEvent(any(), any()) } + @Test + fun `data collection filters cookies`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.cookies = KeyValueCollectionBehavior.denyList("theme") + }, + ) + + sut.get(fixture.server.url("/hello").toString()) { + headers["Cookie"] = "language=en; theme=dark; sessionId=secret" + } + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals( + "language=en; theme=[Filtered]; sessionId=[Filtered]", + it.request!!.cookies, + ) + assertEquals("theme=[Filtered]; Path=/", it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection can disable cookies`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + sendDefaultPii = true, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.cookies = KeyValueCollectionBehavior.off() + }, + ) + + sut.get(fixture.server.url("/hello").toString()) { headers["Cookie"] = "theme=dark" } + + verify(fixture.scopes) + .captureEvent( + check { + assertNull(it.request!!.cookies) + assertNull(it.contexts.response!!.cookies) + }, + any(), + ) + } + @Test fun `data collection filters request headers`(): Unit = runBlocking { val sut = 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 ce8759e571..9299e23660 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt @@ -37,8 +37,7 @@ internal object SentryOkHttpUtils { val sentryRequest = io.sentry.protocol.Request().apply { urlDetails.applyToRequest(this) - // Cookie is only sent if isSendDefaultPii is enabled - cookies = if (scopes.options.isSendDefaultPii) request.headers["Cookie"] else null + cookies = getRequestCookies(scopes, request.headers["Cookie"]) method = request.method headers = getRequestHeaders(scopes, request.headers) @@ -47,8 +46,7 @@ internal object SentryOkHttpUtils { val sentryResponse = io.sentry.protocol.Response().apply { - // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII - cookies = if (scopes.options.isSendDefaultPii) response.headers["Set-Cookie"] else null + cookies = getResponseCookies(scopes, response.headers["Set-Cookie"]) headers = getResponseHeaders(scopes, response.headers) statusCode = response.code @@ -67,6 +65,28 @@ internal object SentryOkHttpUtils { } } + private fun getRequestCookies(scopes: IScopes, cookies: String?): String? = + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterCookies( + cookies, + scopes.options.dataCollectionResolver.cookies, + null, + ) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + + private fun getResponseCookies(scopes: IScopes, cookies: String?): String? = + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + private fun getRequestHeaders( scopes: IScopes, requestHeaders: Headers, 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 d29e8df826..8ca2aa6f3c 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt @@ -69,7 +69,7 @@ class SentryOkHttpUtilsTest { private fun getRequest(url: String = "/hello"): Request = Request.Builder() .addHeader("myHeader", "myValue") - .addHeader("Cookie", "cookie") + .addHeader("Cookie", "theme=dark; sessionId=secret") .get() .url(fixture.server.url(url)) .build() @@ -124,6 +124,62 @@ class SentryOkHttpUtilsTest { ) } + @Test + fun `data collection filters request cookies`() { + val sut = fixture.getSut { + dataCollection.cookies = KeyValueCollectionBehavior.denyList("theme") + } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("theme=[Filtered]; sessionId=[Filtered]", it.request!!.cookies) + assertEquals("setCookie", it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection can disable cookies`() { + val sut = fixture.getSut { dataCollection.cookies = KeyValueCollectionBehavior.off() } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent( + check { + assertNull(it.request!!.cookies) + assertNull(it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection cookie defaults ignore sendDefaultPii`() { + val sut = fixture.getSut(sendDefaultPii = false) { dataCollection.setUserInfo(false) } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("theme=dark; sessionId=[Filtered]", it.request!!.cookies) + assertEquals("setCookie", it.contexts.response!!.cookies) + }, + any(), + ) + } + @Test fun `data collection filters request headers`() { val sut = fixture.getSut { 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 0f1ef3fcff..c66362fe4e 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 @@ -48,12 +48,19 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); - if (scopes.getOptions().isSendDefaultPii()) { - String cookieName = HttpUtils.COOKIE_HEADER_NAME; - final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( - httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames); - sentryRequest.setCookies(toString(filteredHeaders)); + final @NotNull String cookieName = HttpUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterCookiesFromHeader( + httpRequest.getHeaders(cookieName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + additionalSecurityCookieNames))); + } else if (scopes.getOptions().isSendDefaultPii()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterOutSecurityCookiesFromHeader( + httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames))); } return sentryRequest; } 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 a355d379a8..0d78568074 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 @@ -37,8 +37,15 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); - if (scopes.getOptions().isSendDefaultPii()) { - String headerName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String headerName = HttpUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterCookiesFromHeader( + httpRequest.getHeaders().get(headerName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + Collections.emptyList()))); + } else if (scopes.getOptions().isSendDefaultPii()) { sentryRequest.setCookies( toString( HttpUtils.filterOutSecurityCookiesFromHeader( 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 81f053f32a..94316e3ed4 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 @@ -48,12 +48,19 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); - if (scopes.getOptions().isSendDefaultPii()) { - String cookieName = HttpUtils.COOKIE_HEADER_NAME; - final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( - httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames); - sentryRequest.setCookies(toString(filteredHeaders)); + final @NotNull String cookieName = HttpUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterCookiesFromHeader( + httpRequest.getHeaders(cookieName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + additionalSecurityCookieNames))); + } else if (scopes.getOptions().isSendDefaultPii()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterOutSecurityCookiesFromHeader( + httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames))); } return sentryRequest; } 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 8a5cf168aa..6383e02fbd 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 @@ -37,8 +37,15 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); - if (scopes.getOptions().isSendDefaultPii()) { - String headerName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String headerName = HttpUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterCookiesFromHeader( + httpRequest.getHeaders().get(headerName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + Collections.emptyList()))); + } else if (scopes.getOptions().isSendDefaultPii()) { sentryRequest.setCookies( toString( HttpUtils.filterOutSecurityCookiesFromHeader( 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 b33f51e41a..dcbc697c16 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java @@ -48,12 +48,19 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); - if (scopes.getOptions().isSendDefaultPii()) { - String cookieName = HttpUtils.COOKIE_HEADER_NAME; - final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( - httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames); - sentryRequest.setCookies(toString(filteredHeaders)); + final @NotNull String cookieName = HttpUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterCookiesFromHeader( + httpRequest.getHeaders(cookieName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + additionalSecurityCookieNames))); + } else if (scopes.getOptions().isSendDefaultPii()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterOutSecurityCookiesFromHeader( + httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames))); } return sentryRequest; } 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 7c6ecfb5ad..27c1a754be 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 @@ -37,8 +37,15 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); - if (scopes.getOptions().isSendDefaultPii()) { - String headerName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String headerName = HttpUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterCookiesFromHeader( + httpRequest.getHeaders().get(headerName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + Collections.emptyList()))); + } else if (scopes.getOptions().isSendDefaultPii()) { sentryRequest.setCookies( toString( HttpUtils.filterOutSecurityCookiesFromHeader( 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 b33ad7731d..ea92a49ceb 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt @@ -205,6 +205,55 @@ class SentrySpringFilterTest { assertNotNull(fixture.scope.request) { assertNull(it.cookies) } } + @Test + fun `data collection filters cookies and ignores sendDefaultPii`() { + val sentryOptions = + SentryOptions().apply { + isSendDefaultPii = false + dataCollection.cookies = KeyValueCollectionBehavior.denyList("customer") + } + + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header( + "Cookie", + "theme=dark; sessionId=secret; customerId=123; customSession=456", + ) + .buildRequest(servletContextWithCustomCookieName("customSession")), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertEquals( + "theme=dark; sessionId=[Filtered]; customerId=[Filtered]; customSession=[Filtered]", + fixture.scope.request!!.cookies, + ) + } + + @Test + fun `data collection can disable cookies`() { + val sentryOptions = + SentryOptions().apply { + isSendDefaultPii = true + dataCollection.cookies = KeyValueCollectionBehavior.off() + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header("Cookie", "theme=dark") + .buildRequest(MockServletContext()), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertNull(fixture.scope.request!!.cookies) + } + @Test fun `data collection filters request headers`() { val sentryOptions = diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d9aa46e379..30f28e17d6 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7806,11 +7806,15 @@ 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 filterCookies (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/lang/String; + public static fun filterCookiesFromHeader (Ljava/util/Enumeration;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/util/List; + public static fun filterCookiesFromHeader (Ljava/util/List;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/util/List; public static fun 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 filterSetCookie (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 diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index 936571f4ba..ffa785f000 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -111,6 +111,95 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { return filteredQuery.toString(); } + public static @Nullable List filterCookiesFromHeader( + final @Nullable Enumeration headers, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + return headers == null + ? null + : filterCookiesFromHeader( + Collections.list(headers), behavior, additionalSensitiveCookieNames); + } + + public static @Nullable List filterCookiesFromHeader( + final @Nullable List headers, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + if (headers == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + final @NotNull List filteredHeaders = new ArrayList<>(); + for (final String header : headers) { + filteredHeaders.add(filterCookies(header, behavior, additionalSensitiveCookieNames)); + } + return filteredHeaders; + } + + public static @Nullable String filterCookies( + final @Nullable String cookies, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + if (cookies == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + try { + final @NotNull String[] cookieValues = cookies.split(";", -1); + final @NotNull StringBuilder filteredCookies = new StringBuilder(); + for (int i = 0; i < cookieValues.length; i++) { + if (i > 0) { + filteredCookies.append(';'); + } + filteredCookies.append( + filterCookie(cookieValues[i], behavior, additionalSensitiveCookieNames)); + } + return filteredCookies.toString(); + } catch (Throwable ignored) { + return null; + } + } + + public static @Nullable String filterSetCookie( + final @Nullable String cookie, final @NotNull KeyValueCollectionBehavior behavior) { + if (cookie == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + try { + final int attributesSeparator = cookie.indexOf(';'); + final @NotNull String cookieValue = + attributesSeparator < 0 ? cookie : cookie.substring(0, attributesSeparator); + final @NotNull String attributes = + attributesSeparator < 0 ? "" : cookie.substring(attributesSeparator); + return filterCookie(cookieValue, behavior, null) + attributes; + } catch (Throwable ignored) { + return null; + } + } + + private static @NotNull String filterCookie( + final @NotNull String cookie, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + final int separator = cookie.indexOf('='); + final @NotNull String name = separator < 0 ? cookie : cookie.substring(0, separator); + final @NotNull String normalizedName = name.trim(); + final boolean sensitive = + containsTerm(normalizedName, SENSITIVE_DATA_KEYS) + || isSecurityCookie(normalizedName, additionalSensitiveCookieNames); + final boolean matchesTerm = containsTerm(normalizedName, behavior.getTerms()); + final boolean shouldFilter = + sensitive + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.DENY_LIST && matchesTerm) + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.ALLOW_LIST && !matchesTerm); + + if (shouldFilter) { + return name + "=" + SENSITIVE_DATA_SUBSTITUTE; + } + return cookie; + } + public static @NotNull Map filterHeaders( final @NotNull Map headers, final @NotNull KeyValueCollectionBehavior behavior) { diff --git a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt index 1da3b82b51..3eb3401a32 100644 --- a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt @@ -59,6 +59,102 @@ class HttpUtilsTest { .isEqualTo("name=&flag&&token=[Filtered]") } + @Test + fun `cookie filter disables collection in off mode`() { + assertThat( + HttpUtils.filterCookies( + "name=value", + KeyValueCollectionBehavior.off(), + emptyList(), + ) + ) + .isNull() + } + + @Test + fun `cookie deny list filters built-in configured and integration sensitive names`() { + assertThat( + HttpUtils.filterCookies( + "name=value; sessionId=secret; customerId=123; frameworkSession=456", + KeyValueCollectionBehavior.denyList("customer"), + listOf("frameworkSession"), + ) + ) + .isEqualTo( + "name=value; sessionId=[Filtered]; customerId=[Filtered]; frameworkSession=[Filtered]" + ) + } + + @Test + fun `cookie allow list only retains allowed non-sensitive values`() { + assertThat( + HttpUtils.filterCookies( + "theme=dark; sessionId=secret; language=en", + KeyValueCollectionBehavior.allowList("theme", "session"), + emptyList(), + ) + ) + .isEqualTo("theme=dark; sessionId=[Filtered]; language=[Filtered]") + } + + @Test + fun `cookie filter uses only the first equals separator`() { + assertThat( + HttpUtils.filterCookies( + "theme=dark=contrast; token=abc=123", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark=contrast; token=[Filtered]") + } + + @Test + fun `set cookie filter preserves attributes`() { + assertThat( + HttpUtils.filterSetCookie( + "sessionId=secret; Path=/; HttpOnly; SameSite=Lax", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("sessionId=[Filtered]; Path=/; HttpOnly; SameSite=Lax") + } + + @Test + fun `set cookie allow list retains allowed non-sensitive value and attributes`() { + assertThat( + HttpUtils.filterSetCookie( + "theme=dark; Path=/; Secure", + KeyValueCollectionBehavior.allowList("theme"), + ) + ) + .isEqualTo("theme=dark; Path=/; Secure") + } + + @Test + fun `set cookie filter disables collection in off mode`() { + assertThat( + HttpUtils.filterSetCookie( + "theme=dark; Path=/", + KeyValueCollectionBehavior.off(), + ) + ) + .isNull() + } + + @Test + fun `cookie header filter processes every header value`() { + assertThat( + HttpUtils.filterCookiesFromHeader( + listOf("theme=dark; SID=secret", "language=en"), + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .containsExactly("theme=dark; SID=[Filtered]", "language=en") + .inOrder() + } + @Test fun `header filter disables collection in off mode`() { val filtered =