From 268920f195b1dca895ad39eb139d3e3bf035f114 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 22 Jul 2026 08:57:58 +0200 Subject: [PATCH] feat(http): Apply cookie collection policy Filter automatically captured request and response cookies according to the Data Collection policy across Spring and HTTP client integrations. Preserve existing sendDefaultPii behavior when Data Collection is absent. Co-Authored-By: Claude --- .../apollo3/SentryApollo3HttpInterceptor.kt | 34 +++++-- .../SentryApollo3InterceptorClientErrors.kt | 53 ++++++++-- .../apollo4/SentryApollo4HttpInterceptor.kt | 34 +++++-- ...pollo4BuilderExtensionsClientErrorsTest.kt | 53 ++++++++-- .../ktorClient/SentryKtorClientUtils.kt | 28 +++++- .../ktorClient/SentryKtorClientPluginTest.kt | 55 +++++++++++ .../io/sentry/okhttp/SentryOkHttpUtils.kt | 28 +++++- .../io/sentry/okhttp/SentryOkHttpUtilsTest.kt | 58 ++++++++++- .../sentry/spring7/SentryRequestResolver.java | 19 ++-- .../webflux/SentryRequestResolver.java | 11 ++- .../spring/jakarta/SentryRequestResolver.java | 19 ++-- .../webflux/SentryRequestResolver.java | 11 ++- .../sentry/spring/SentryRequestResolver.java | 19 ++-- .../spring/webflux/SentryRequestResolver.java | 11 ++- .../sentry/spring/SentrySpringFilterTest.kt | 49 ++++++++++ sentry/api/sentry.api | 4 + .../main/java/io/sentry/util/HttpUtils.java | 89 +++++++++++++++++ .../test/java/io/sentry/util/HttpUtilsTest.kt | 96 +++++++++++++++++++ 18 files changed, 606 insertions(+), 65 deletions(-) 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 =