From 81fc72d4de88496d2469fac1e6206461223f362e Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Tue, 3 Mar 2026 12:38:31 +0000 Subject: [PATCH 1/8] redacting hostname from flight recorder logs --- .../FlightRecorderWriterImpl.kt | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt index 974a87ce437..4bd9263e6fa 100644 --- a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt +++ b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt @@ -103,10 +103,10 @@ internal class FlightRecorderWriterImpl( bw.append(it) } bw.append(" – ") - bw.append(message) + bw.append(message.redactUrls()) throwable?.let { bw.append(" – ") - bw.append(it.getStackTraceString()) + bw.append(it.getStackTraceString().redactUrls()) } bw.newLine() } @@ -141,3 +141,24 @@ private val Int.logLevel: String Log.ASSERT -> "ASSERT" else -> "UNKNOWN" } + +/** + * Redacts URLs and quoted hostnames in the string by replacing them with [REDACTED]. + * Handles both full URLs and hostnames in quotes (e.g., "Unable to resolve host "example.com""). + */ +@Suppress("MagicNumber") +private fun String.redactUrls(): String { + val urlPattern = Regex("""(https?://)([\w.-]+)((?:/[\w./?&=%-]*)?)""") + val afterUrlRedaction = urlPattern.replace(this) { matchResult -> + val protocol = matchResult.groupValues[1] + val path = matchResult.groupValues[3] + "$protocol[REDACTED]$path" + } + + // Redact hostnames that appear in double quotes without protocol and path + // This handles cases like: Unable to resolve host "com.example.server" + val quotedHostnamePattern = Regex(""""([\w-]+\.[\w.-]+)"""") + return quotedHostnamePattern.replace(afterUrlRedaction) { + """"[REDACTED]"""" + } +} From 45c195a7d0f828bb4c19396b8a0f8855ddee7537 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Mon, 9 Mar 2026 17:44:26 +0000 Subject: [PATCH 2/8] Redacting hostname when it matches a self-hosted hostname Replacing api url with selfhosted one on toFailure at NetworkResultCall --- .../data/manager/di/DataManagerModule.kt | 3 + .../FlightRecorderWriterImpl.kt | 52 ++++--- .../network/core/NetworkResultCall.kt | 34 +++- .../network/core/NetworkResultCallAdapter.kt | 5 +- .../core/NetworkResultCallAdapterFactory.kt | 10 +- .../interceptor/BaseUrlInterceptors.kt | 2 +- .../network/retrofit/RetrofitsImpl.kt | 6 +- .../network/util/HostnameRedactionUtil.kt | 27 ++++ .../core/NetworkResultCallAdapterTest.kt | 11 +- .../network/retrofit/RetrofitsTest.kt | 1 + .../network/util/HostnameRedactionUtilTest.kt | 146 ++++++++++++++++++ .../bitwarden/network/base/BaseServiceTest.kt | 9 +- 12 files changed, 272 insertions(+), 34 deletions(-) create mode 100644 network/src/main/kotlin/com/bitwarden/network/util/HostnameRedactionUtil.kt create mode 100644 network/src/test/kotlin/com/bitwarden/network/util/HostnameRedactionUtilTest.kt diff --git a/data/src/main/kotlin/com/bitwarden/data/manager/di/DataManagerModule.kt b/data/src/main/kotlin/com/bitwarden/data/manager/di/DataManagerModule.kt index d7bf729e572..6925a65fe69 100644 --- a/data/src/main/kotlin/com/bitwarden/data/manager/di/DataManagerModule.kt +++ b/data/src/main/kotlin/com/bitwarden/data/manager/di/DataManagerModule.kt @@ -17,6 +17,7 @@ import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager import com.bitwarden.data.manager.flightrecorder.FlightRecorderManagerImpl import com.bitwarden.data.manager.flightrecorder.FlightRecorderWriter import com.bitwarden.data.manager.flightrecorder.FlightRecorderWriterImpl +import com.bitwarden.network.interceptor.BaseUrlsProvider import com.bitwarden.network.service.DownloadService import dagger.Module import dagger.Provides @@ -80,11 +81,13 @@ object DataManagerModule { fileManager: FileManager, dispatcherManager: DispatcherManager, buildInfoManager: BuildInfoManager, + baseUrlsProvider: BaseUrlsProvider, ): FlightRecorderWriter = FlightRecorderWriterImpl( clock = clock, fileManager = fileManager, dispatcherManager = dispatcherManager, buildInfoManager = buildInfoManager, + baseUrlsProvider = baseUrlsProvider, ) @Provides diff --git a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt index 4bd9263e6fa..2826ad2479e 100644 --- a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt +++ b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt @@ -8,7 +8,10 @@ import com.bitwarden.core.data.manager.dispatcher.DispatcherManager import com.bitwarden.core.data.util.toFormattedPattern import com.bitwarden.data.datasource.disk.model.FlightRecorderDataSet import com.bitwarden.data.manager.file.FileManager +import com.bitwarden.network.interceptor.BaseUrlsProvider +import com.bitwarden.network.util.redactHostnamesInMessage import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import timber.log.Timber import java.io.BufferedWriter import java.io.File @@ -30,6 +33,7 @@ internal class FlightRecorderWriterImpl( private val fileManager: FileManager, private val dispatcherManager: DispatcherManager, private val buildInfoManager: BuildInfoManager, + private val baseUrlsProvider: BaseUrlsProvider, ) : FlightRecorderWriter { override suspend fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) { fileManager.delete(File(File(fileManager.logsDirectory), data.fileName)) @@ -103,16 +107,39 @@ internal class FlightRecorderWriterImpl( bw.append(it) } bw.append(" – ") - bw.append(message.redactUrls()) + bw.append(message.redactUrls()) // Apply hostname redaction throwable?.let { bw.append(" – ") - bw.append(it.getStackTraceString().redactUrls()) + bw.append(it.getStackTraceString().redactUrls()) // Also redact stack traces } bw.newLine() } } } } + + /** + * Redacts ONLY the user's configured self-hosted server hostname. + * + * Preserves ALL Bitwarden domains (including QA/staging). + * Delegates to [com.bitwarden.network.util.redactHostnamesInMessage]. + * + * Examples: + * - "https://api.bitwarden.com/sync" → unchanged (Bitwarden cloud) + * - "https://vault.qa.bitwarden.pw/api" → unchanged (Bitwarden QA) + * - "https://vault.example.com/api" → "https://[REDACTED_SELF_HOST]/api" (self-hosted) + */ + private fun String.redactUrls(): String { + // Get configured hostnames from BaseUrlsProvider + val configuredHosts = setOf( + baseUrlsProvider.getBaseApiUrl().toHttpUrlOrNull()?.host, + baseUrlsProvider.getBaseIdentityUrl().toHttpUrlOrNull()?.host, + baseUrlsProvider.getBaseEventsUrl().toHttpUrlOrNull()?.host, + ).filterNotNull().toSet() + + // Delegate to HostnameRedactionUtil for all redaction logic + return this.redactHostnamesInMessage(configuredHosts) + } } /** @@ -141,24 +168,3 @@ private val Int.logLevel: String Log.ASSERT -> "ASSERT" else -> "UNKNOWN" } - -/** - * Redacts URLs and quoted hostnames in the string by replacing them with [REDACTED]. - * Handles both full URLs and hostnames in quotes (e.g., "Unable to resolve host "example.com""). - */ -@Suppress("MagicNumber") -private fun String.redactUrls(): String { - val urlPattern = Regex("""(https?://)([\w.-]+)((?:/[\w./?&=%-]*)?)""") - val afterUrlRedaction = urlPattern.replace(this) { matchResult -> - val protocol = matchResult.groupValues[1] - val path = matchResult.groupValues[3] - "$protocol[REDACTED]$path" - } - - // Redact hostnames that appear in double quotes without protocol and path - // This handles cases like: Unable to resolve host "com.example.server" - val quotedHostnamePattern = Regex(""""([\w-]+\.[\w.-]+)"""") - return quotedHostnamePattern.replace(afterUrlRedaction) { - """"[REDACTED]"""" - } -} diff --git a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt index 9e6d7c0f0d3..da83170c165 100644 --- a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt +++ b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt @@ -1,6 +1,8 @@ package com.bitwarden.network.core +import com.bitwarden.network.interceptor.BaseUrlsProvider import com.bitwarden.network.model.NetworkResult +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Request import okio.IOException import okio.Timeout @@ -23,10 +25,12 @@ private const val NO_CONTENT_RESPONSE_CODE: Int = 204 internal class NetworkResultCall( private val backingCall: Call, private val successType: Type, + private val baseUrlsProvider: BaseUrlsProvider? = null, ) : Call> { override fun cancel(): Unit = backingCall.cancel() - override fun clone(): Call> = NetworkResultCall(backingCall, successType) + override fun clone(): Call> = + NetworkResultCall(backingCall, successType, baseUrlsProvider) override fun enqueue(callback: Callback>): Unit = backingCall.enqueue( object : Callback { @@ -67,8 +71,32 @@ internal class NetworkResultCall( fun executeForResult(): NetworkResult = requireNotNull(execute().body()) private fun Throwable.toFailure(): NetworkResult { - // We rebuild the URL without query params, we do not want to log those - val url = backingCall.request().url.toUrl().run { "$protocol://$authority$path" } + val originalUrl = backingCall.request().url.toUrl() + + // Check if this is a hardcoded default URL that will be replaced by BaseUrlInterceptor + // Match against the defaults from RetrofitsImpl.kt line 111 and EnvironmentUrlDataJson + val actualHost = if (baseUrlsProvider != null) { + when (originalUrl.host) { + "api.bitwarden.com" -> baseUrlsProvider.getBaseApiUrl().toHttpUrlOrNull()?.host + "identity.bitwarden.com" -> baseUrlsProvider.getBaseIdentityUrl() + .toHttpUrlOrNull()?.host + + "events.bitwarden.com" -> baseUrlsProvider.getBaseEventsUrl() + .toHttpUrlOrNull()?.host + + else -> null + } + } else { + null + } + + // Rebuild the URL without query params, using actual host if available + val url = if (actualHost != null) { + "${originalUrl.protocol}://$actualHost${originalUrl.path}" + } else { + "${originalUrl.protocol}://${originalUrl.authority}${originalUrl.path}" + } + Timber.w(this, "Network Error: $url") return NetworkResult.Failure(this) } diff --git a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt index 2cbd9b41e5b..7265139c6e0 100644 --- a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt +++ b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt @@ -1,5 +1,6 @@ package com.bitwarden.network.core +import com.bitwarden.network.interceptor.BaseUrlsProvider import com.bitwarden.network.model.NetworkResult import retrofit2.Call import retrofit2.CallAdapter @@ -10,8 +11,10 @@ import java.lang.reflect.Type */ internal class NetworkResultCallAdapter( private val successType: Type, + private val baseUrlsProvider: BaseUrlsProvider, ) : CallAdapter>> { override fun responseType(): Type = successType - override fun adapt(call: Call): Call> = NetworkResultCall(call, successType) + override fun adapt(call: Call): Call> = + NetworkResultCall(call, successType, baseUrlsProvider) } diff --git a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterFactory.kt b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterFactory.kt index 7f08056af4b..fc7c0d5e03d 100644 --- a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterFactory.kt +++ b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterFactory.kt @@ -1,5 +1,6 @@ package com.bitwarden.network.core +import com.bitwarden.network.interceptor.BaseUrlsProvider import com.bitwarden.network.model.NetworkResult import retrofit2.Call import retrofit2.CallAdapter @@ -10,7 +11,9 @@ import java.lang.reflect.Type /** * A [retrofit2.CallAdapter.Factory] for wrapping network requests into [NetworkResult]. */ -internal class NetworkResultCallAdapterFactory : CallAdapter.Factory() { +internal class NetworkResultCallAdapterFactory( + private val baseUrlsProvider: BaseUrlsProvider, +) : CallAdapter.Factory() { override fun get( returnType: Type, annotations: Array, @@ -25,7 +28,10 @@ internal class NetworkResultCallAdapterFactory : CallAdapter.Factory() { val requestType = getParameterUpperBound(0, containerType) return if (getRawType(returnType) == Call::class.java) { - NetworkResultCallAdapter(successType = requestType) + NetworkResultCallAdapter( + successType = requestType, + baseUrlsProvider = baseUrlsProvider, + ) } else { null } diff --git a/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlInterceptors.kt b/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlInterceptors.kt index dceaf9f5e54..0f078df146d 100644 --- a/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlInterceptors.kt +++ b/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlInterceptors.kt @@ -7,7 +7,7 @@ import com.bitwarden.annotation.OmitFromCoverage */ @OmitFromCoverage internal class BaseUrlInterceptors( - private val baseUrlsProvider: BaseUrlsProvider, + val baseUrlsProvider: BaseUrlsProvider, ) { /** * An interceptor for "/api" calls. diff --git a/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt b/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt index 786cc9d3895..98da4f7d412 100644 --- a/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt @@ -23,7 +23,7 @@ import timber.log.Timber @Suppress("LongParameterList") internal class RetrofitsImpl( authTokenManager: AuthTokenManager, - baseUrlInterceptors: BaseUrlInterceptors, + private val baseUrlInterceptors: BaseUrlInterceptors, cookieInterceptor: CookieInterceptor, headersInterceptor: HeadersInterceptor, json: Json, @@ -115,7 +115,9 @@ internal class RetrofitsImpl( private val baseRetrofitBuilder: Retrofit.Builder by lazy { Retrofit.Builder() .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) - .addCallAdapterFactory(NetworkResultCallAdapterFactory()) + .addCallAdapterFactory( + NetworkResultCallAdapterFactory(baseUrlInterceptors.baseUrlsProvider), + ) .client(baseOkHttpClient) } diff --git a/network/src/main/kotlin/com/bitwarden/network/util/HostnameRedactionUtil.kt b/network/src/main/kotlin/com/bitwarden/network/util/HostnameRedactionUtil.kt new file mode 100644 index 00000000000..aa8e557d840 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/util/HostnameRedactionUtil.kt @@ -0,0 +1,27 @@ +package com.bitwarden.network.util + +/** + * List of official Bitwarden cloud hostnames that are safe to log. + */ +private val BITWARDEN_HOSTS = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw") + +/** + * Redacts hostnames in a log message by replacing bare hostnames with [REDACTED_SELF_HOST]. + * + * Only redacts hostnames that match [configuredHosts] AND are not official Bitwarden domains. + * Preserves all Bitwarden domains (including QA/dev environments). + * + * @param configuredHosts Set of hostnames from BaseUrlsProvider + * @return Message with hostnames redacted as [REDACTED_SELF_HOST] + */ +fun String.redactHostnamesInMessage(configuredHosts: Set): String = + configuredHosts.fold(this) { result, hostname -> + val escapedHostname = Regex.escape(hostname) + val bareHostnamePattern = Regex("""\b$escapedHostname\b""") + bareHostnamePattern.replace(result) { hostname.redactIfSelfHosted() } + } + +private fun String.redactIfSelfHosted(): String { + val isBitwardenHost = BITWARDEN_HOSTS.any { this.endsWith(it) } + return if (isBitwardenHost) this else "[REDACTED_SELF_HOST]" +} diff --git a/network/src/test/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterTest.kt b/network/src/test/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterTest.kt index 0c523585477..869a6376682 100644 --- a/network/src/test/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterTest.kt @@ -1,6 +1,9 @@ package com.bitwarden.network.core +import com.bitwarden.network.interceptor.BaseUrlsProvider import com.bitwarden.network.model.NetworkResult +import io.mockk.every +import io.mockk.mockk import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -13,12 +16,18 @@ import retrofit2.http.GET class NetworkResultCallAdapterTest { + private val mockBaseUrlsProvider = mockk { + every { getBaseApiUrl() } returns "https://api.bitwarden.com" + every { getBaseIdentityUrl() } returns "https://identity.bitwarden.com" + every { getBaseEventsUrl() } returns "https://events.bitwarden.com" + } + private val server: MockWebServer = MockWebServer().apply { start() } private val testService: FakeService = Retrofit.Builder() .baseUrl(server.url("/").toString()) // add the adapter being tested - .addCallAdapterFactory(NetworkResultCallAdapterFactory()) + .addCallAdapterFactory(NetworkResultCallAdapterFactory(mockBaseUrlsProvider)) .build() .create() diff --git a/network/src/test/kotlin/com/bitwarden/network/retrofit/RetrofitsTest.kt b/network/src/test/kotlin/com/bitwarden/network/retrofit/RetrofitsTest.kt index eaeb8a77a44..5c54368ed01 100644 --- a/network/src/test/kotlin/com/bitwarden/network/retrofit/RetrofitsTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/retrofit/RetrofitsTest.kt @@ -37,6 +37,7 @@ class RetrofitsTest { mockIntercept { isAuthInterceptorCalled = true } } private val baseUrlInterceptors = mockk { + every { baseUrlsProvider } returns mockk(relaxed = true) every { apiInterceptor } returns mockk { mockIntercept { isApiInterceptorCalled = true } } diff --git a/network/src/test/kotlin/com/bitwarden/network/util/HostnameRedactionUtilTest.kt b/network/src/test/kotlin/com/bitwarden/network/util/HostnameRedactionUtilTest.kt new file mode 100644 index 00000000000..1dbc3c2cbf2 --- /dev/null +++ b/network/src/test/kotlin/com/bitwarden/network/util/HostnameRedactionUtilTest.kt @@ -0,0 +1,146 @@ +package com.bitwarden.network.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class HostnameRedactionUtilTest { + @Test + fun `redactHostnamesInMessage redacts configured self-hosted URLs`() { + val message = "--> GET https://vault.example.com/api/sync HTTP/1.1" + val configuredHosts = setOf("vault.example.com") + + val result = message.redactHostnamesInMessage(configuredHosts) + + assertEquals("--> GET https://[REDACTED_SELF_HOST]/api/sync HTTP/1.1", result) + } + + @Test + fun `redactHostnamesInMessage preserves non-configured URLs`() { + val message = "--> GET https://vault.example.com/api/sync HTTP/1.1" + val configuredHosts = setOf("api.bitwarden.com") // Different host + + val result = message.redactHostnamesInMessage(configuredHosts) + + assertEquals(message, result) // Unchanged - not in configured hosts + } + + @Test + fun `redactHostnamesInMessage preserves Bitwarden URLs even if configured`() { + val message = "--> GET https://vault.qa.bitwarden.pw/api/sync HTTP/1.1" + val configuredHosts = setOf("vault.qa.bitwarden.pw") + + val result = message.redactHostnamesInMessage(configuredHosts) + + assertEquals(message, result) // Unchanged - Bitwarden domain preserved + } + + @Test + fun `redactHostnamesInMessage redacts quoted hostnames in error messages`() { + val message = """Unable to resolve host "vault.example.com": No address""" + val configuredHosts = setOf("vault.example.com") + + val result = message.redactHostnamesInMessage(configuredHosts) + + assertEquals("""Unable to resolve host "[REDACTED_SELF_HOST]": No address""", result) + } + + @Test + fun `redactHostnamesInMessage handles multiple URLs in one message`() { + val message = "Redirect from https://old.corp.com to https://new.corp.com" + val configuredHosts = setOf("old.corp.com", "new.corp.com") + + val result = message.redactHostnamesInMessage(configuredHosts) + + assertEquals( + "Redirect from https://[REDACTED_SELF_HOST] to https://[REDACTED_SELF_HOST]", + result, + ) + } + + @Test + fun `redactHostnamesInMessage handles empty configured hosts`() { + val message = "--> GET https://vault.example.com/api HTTP/1.1" + val configuredHosts = emptySet() + + val result = message.redactHostnamesInMessage(configuredHosts) + + assertEquals(message, result) // Unchanged - no hosts to redact + } + + @Test + fun `redactHostnamesInMessage handles NetworkCookieManagerImpl getCookies pattern`() { + val message = "2026-03-09 12:43:29:857 – DEBUG – NetworkCookieManagerImpl – " + + "getCookies(vault.example.com): resolved=vault.example.com, count=0" + val configuredHosts = setOf("vault.example.com") + + val result = message.redactHostnamesInMessage(configuredHosts) + + assertEquals( + "2026-03-09 12:43:29:857 – DEBUG – NetworkCookieManagerImpl – " + + "getCookies([REDACTED_SELF_HOST]): resolved=[REDACTED_SELF_HOST], count=0", + result, + ) + } + + @Test + fun `redactHostnamesInMessage preserves Bitwarden domains in NetworkCookieManagerImpl logs`() { + val message = "2026-03-09 12:43:29:857 – DEBUG – NetworkCookieManagerImpl – " + + "getCookies(vault.example.com): resolved=vault.qa.bitwarden.pw, count=0" + val configuredHosts = setOf("vault.example.com", "vault.qa.bitwarden.pw") + + val result = message.redactHostnamesInMessage(configuredHosts) + + assertEquals( + "2026-03-09 12:43:29:857 – DEBUG – NetworkCookieManagerImpl – " + + "getCookies([REDACTED_SELF_HOST]): resolved=vault.qa.bitwarden.pw, count=0", + result, + ) + } + + @Test + fun `redactHostnamesInMessage handles UnknownHostException error message`() { + val message = "DEBUG – BitwardenNetworkClient – <-- HTTP FAILED: " + + "java.net.UnknownHostException: Unable to resolve host " + + "\"vault.example.com\": No address associated with hostname." + val configuredHosts = setOf("vault.example.com") + + val result = message.redactHostnamesInMessage(configuredHosts) + + assertEquals( + "DEBUG – BitwardenNetworkClient – <-- HTTP FAILED: " + + "java.net.UnknownHostException: Unable to resolve host " + + "\"[REDACTED_SELF_HOST]\": No address associated with hostname.", + result, + ) + } + + @Test + fun `redactHostnamesInMessage handles needsBootstrap pattern`() { + val message = "2026-03-09 12:43:29:851 – DEBUG – NetworkCookieManagerImpl – " + + "needsBootstrap(vault.example.com): false (cookieDomain=null)" + val configuredHosts = setOf("vault.example.com") + + val result = message.redactHostnamesInMessage(configuredHosts) + + assertEquals( + "2026-03-09 12:43:29:851 – DEBUG – NetworkCookieManagerImpl – " + + "needsBootstrap([REDACTED_SELF_HOST]): false (cookieDomain=null)", + result, + ) + } + + @Test + fun `redactHostnamesInMessage handles resolveHostname pattern`() { + val message = "2026-03-09 12:43:29:855 – DEBUG – NetworkCookieManagerImpl – " + + "resolveHostname(vault.example.com): no stored config found, using original" + val configuredHosts = setOf("vault.example.com") + + val result = message.redactHostnamesInMessage(configuredHosts) + + assertEquals( + "2026-03-09 12:43:29:855 – DEBUG – NetworkCookieManagerImpl – " + + "resolveHostname([REDACTED_SELF_HOST]): no stored config found, using original", + result, + ) + } +} diff --git a/network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt b/network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt index 5f31899614d..e6ab11bd01b 100644 --- a/network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt +++ b/network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt @@ -2,6 +2,7 @@ package com.bitwarden.network.base import com.bitwarden.core.di.CoreModule import com.bitwarden.network.core.NetworkResultCallAdapterFactory +import com.bitwarden.network.interceptor.BaseUrlsProvider import okhttp3.HttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.mockwebserver.MockWebServer @@ -22,9 +23,15 @@ abstract class BaseServiceTest { protected val urlPrefix: String get() = "http://${server.hostName}:${server.port}" + private val fakeBaseUrlsProvider = object : BaseUrlsProvider { + override fun getBaseApiUrl(): String = "https://api.bitwarden.com" + override fun getBaseIdentityUrl(): String = "https://identity.bitwarden.com" + override fun getBaseEventsUrl(): String = "https://events.bitwarden.com" + } + protected val retrofit: Retrofit = Retrofit.Builder() .baseUrl(url.toString()) - .addCallAdapterFactory(NetworkResultCallAdapterFactory()) + .addCallAdapterFactory(NetworkResultCallAdapterFactory(fakeBaseUrlsProvider)) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() From 78b298512d5c6d3c6e54b00ea769746c0a57593a Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Tue, 10 Mar 2026 13:18:18 +0000 Subject: [PATCH 3/8] simplified method description --- .../manager/flightrecorder/FlightRecorderWriterImpl.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt index 2826ad2479e..e0f13707c0e 100644 --- a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt +++ b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt @@ -121,13 +121,7 @@ internal class FlightRecorderWriterImpl( /** * Redacts ONLY the user's configured self-hosted server hostname. * - * Preserves ALL Bitwarden domains (including QA/staging). - * Delegates to [com.bitwarden.network.util.redactHostnamesInMessage]. - * - * Examples: - * - "https://api.bitwarden.com/sync" → unchanged (Bitwarden cloud) - * - "https://vault.qa.bitwarden.pw/api" → unchanged (Bitwarden QA) - * - "https://vault.example.com/api" → "https://[REDACTED_SELF_HOST]/api" (self-hosted) + * Preserves ALL Bitwarden domains (including QA/dev). */ private fun String.redactUrls(): String { // Get configured hostnames from BaseUrlsProvider From 4a711c80369790760c78a1b88e0939845753a3dc Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Wed, 11 Mar 2026 18:40:22 +0000 Subject: [PATCH 4/8] using let instead of null validation --- .../bitwarden/network/core/NetworkResultCall.kt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt index da83170c165..5ac61efa667 100644 --- a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt +++ b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt @@ -75,19 +75,13 @@ internal class NetworkResultCall( // Check if this is a hardcoded default URL that will be replaced by BaseUrlInterceptor // Match against the defaults from RetrofitsImpl.kt line 111 and EnvironmentUrlDataJson - val actualHost = if (baseUrlsProvider != null) { + val actualHost = baseUrlsProvider?.let { provider -> when (originalUrl.host) { - "api.bitwarden.com" -> baseUrlsProvider.getBaseApiUrl().toHttpUrlOrNull()?.host - "identity.bitwarden.com" -> baseUrlsProvider.getBaseIdentityUrl() - .toHttpUrlOrNull()?.host - - "events.bitwarden.com" -> baseUrlsProvider.getBaseEventsUrl() - .toHttpUrlOrNull()?.host - + "api.bitwarden.com" -> provider.getBaseApiUrl().toHttpUrlOrNull()?.host + "identity.bitwarden.com" -> provider.getBaseIdentityUrl().toHttpUrlOrNull()?.host + "events.bitwarden.com" -> provider.getBaseEventsUrl().toHttpUrlOrNull()?.host else -> null } - } else { - null } // Rebuild the URL without query params, using actual host if available From 189916a0102ac2f2d22c5d71ea397a9df5d905bb Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Thu, 19 Mar 2026 18:43:21 +0000 Subject: [PATCH 5/8] keeping BaseUrlProvider use only on the BaseUrlInterceptors. Adding regex validations to HostnameRedactionUtil in order to redact names Changing the NetworkResultCall to get the correct hostname from the Throwable's message --- .../data/manager/di/DataManagerModule.kt | 3 -- .../FlightRecorderWriterImpl.kt | 26 ++-------- .../network/core/NetworkResultCall.kt | 22 ++------- .../network/core/NetworkResultCallAdapter.kt | 4 +- .../core/NetworkResultCallAdapterFactory.kt | 10 +--- .../interceptor/BaseUrlInterceptors.kt | 2 +- .../network/retrofit/RetrofitsImpl.kt | 6 +-- .../network/util/HostnameRedactionUtil.kt | 32 +++++++++++- .../core/NetworkResultCallAdapterTest.kt | 11 +---- .../network/retrofit/RetrofitsTest.kt | 1 - .../network/util/HostnameRedactionUtilTest.kt | 49 ++++++++++++++++++- .../bitwarden/network/base/BaseServiceTest.kt | 9 +--- 12 files changed, 93 insertions(+), 82 deletions(-) diff --git a/data/src/main/kotlin/com/bitwarden/data/manager/di/DataManagerModule.kt b/data/src/main/kotlin/com/bitwarden/data/manager/di/DataManagerModule.kt index 661ee8cf2f0..a5dfca3e178 100644 --- a/data/src/main/kotlin/com/bitwarden/data/manager/di/DataManagerModule.kt +++ b/data/src/main/kotlin/com/bitwarden/data/manager/di/DataManagerModule.kt @@ -18,7 +18,6 @@ import com.bitwarden.data.manager.flightrecorder.FlightRecorderManagerImpl import com.bitwarden.data.manager.flightrecorder.FlightRecorderWriter import com.bitwarden.data.manager.flightrecorder.FlightRecorderWriterImpl import com.bitwarden.data.repository.ServerConfigRepository -import com.bitwarden.network.interceptor.BaseUrlsProvider import com.bitwarden.network.service.DownloadService import dagger.Module import dagger.Provides @@ -82,14 +81,12 @@ object DataManagerModule { fileManager: FileManager, dispatcherManager: DispatcherManager, buildInfoManager: BuildInfoManager, - baseUrlsProvider: BaseUrlsProvider, serverConfigRepository: ServerConfigRepository, ): FlightRecorderWriter = FlightRecorderWriterImpl( clock = clock, fileManager = fileManager, dispatcherManager = dispatcherManager, buildInfoManager = buildInfoManager, - baseUrlsProvider = baseUrlsProvider, serverConfigRepository = serverConfigRepository, ) diff --git a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt index de61fa40006..2b815092b53 100644 --- a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt +++ b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt @@ -10,10 +10,8 @@ import com.bitwarden.core.data.util.toFormattedPattern import com.bitwarden.data.datasource.disk.model.FlightRecorderDataSet import com.bitwarden.data.manager.file.FileManager import com.bitwarden.data.repository.ServerConfigRepository -import com.bitwarden.network.interceptor.BaseUrlsProvider -import com.bitwarden.network.util.redactHostnamesInMessage +import com.bitwarden.network.util.redactSelfHostedHostnames import kotlinx.coroutines.withContext -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import timber.log.Timber import java.io.BufferedWriter import java.io.File @@ -35,7 +33,6 @@ internal class FlightRecorderWriterImpl( private val fileManager: FileManager, private val dispatcherManager: DispatcherManager, private val buildInfoManager: BuildInfoManager, - private val baseUrlsProvider: BaseUrlsProvider, private val serverConfigRepository: ServerConfigRepository, ) : FlightRecorderWriter { override suspend fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) { @@ -113,33 +110,16 @@ internal class FlightRecorderWriterImpl( bw.append(it) } bw.append(" – ") - bw.append(message.redactUrls()) // Apply hostname redaction + bw.append(message.redactSelfHostedHostnames()) throwable?.let { bw.append(" – ") - bw.append(it.getStackTraceString().redactUrls()) // Also redact stack traces + bw.append(it.getStackTraceString().redactSelfHostedHostnames()) } bw.newLine() } } } } - - /** - * Redacts ONLY the user's configured self-hosted server hostname. - * - * Preserves ALL Bitwarden domains (including QA/dev). - */ - private fun String.redactUrls(): String { - // Get configured hostnames from BaseUrlsProvider - val configuredHosts = setOf( - baseUrlsProvider.getBaseApiUrl().toHttpUrlOrNull()?.host, - baseUrlsProvider.getBaseIdentityUrl().toHttpUrlOrNull()?.host, - baseUrlsProvider.getBaseEventsUrl().toHttpUrlOrNull()?.host, - ).filterNotNull().toSet() - - // Delegate to HostnameRedactionUtil for all redaction logic - return this.redactHostnamesInMessage(configuredHosts) - } } /** diff --git a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt index 5ac61efa667..5a4db3de1fa 100644 --- a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt +++ b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt @@ -1,8 +1,7 @@ package com.bitwarden.network.core -import com.bitwarden.network.interceptor.BaseUrlsProvider import com.bitwarden.network.model.NetworkResult -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import com.bitwarden.network.util.UNKNOWN_HOST_REGEX import okhttp3.Request import okio.IOException import okio.Timeout @@ -25,12 +24,11 @@ private const val NO_CONTENT_RESPONSE_CODE: Int = 204 internal class NetworkResultCall( private val backingCall: Call, private val successType: Type, - private val baseUrlsProvider: BaseUrlsProvider? = null, ) : Call> { override fun cancel(): Unit = backingCall.cancel() override fun clone(): Call> = - NetworkResultCall(backingCall, successType, baseUrlsProvider) + NetworkResultCall(backingCall, successType) override fun enqueue(callback: Callback>): Unit = backingCall.enqueue( object : Callback { @@ -73,20 +71,10 @@ internal class NetworkResultCall( private fun Throwable.toFailure(): NetworkResult { val originalUrl = backingCall.request().url.toUrl() - // Check if this is a hardcoded default URL that will be replaced by BaseUrlInterceptor - // Match against the defaults from RetrofitsImpl.kt line 111 and EnvironmentUrlDataJson - val actualHost = baseUrlsProvider?.let { provider -> - when (originalUrl.host) { - "api.bitwarden.com" -> provider.getBaseApiUrl().toHttpUrlOrNull()?.host - "identity.bitwarden.com" -> provider.getBaseIdentityUrl().toHttpUrlOrNull()?.host - "events.bitwarden.com" -> provider.getBaseEventsUrl().toHttpUrlOrNull()?.host - else -> null - } - } + val extractedHost = message?.let { UNKNOWN_HOST_REGEX.find(it)?.groupValues?.getOrNull(1) } - // Rebuild the URL without query params, using actual host if available - val url = if (actualHost != null) { - "${originalUrl.protocol}://$actualHost${originalUrl.path}" + val url = if (extractedHost != null) { + "${originalUrl.protocol}://$extractedHost${originalUrl.path}" } else { "${originalUrl.protocol}://${originalUrl.authority}${originalUrl.path}" } diff --git a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt index 7265139c6e0..590bdec3ff0 100644 --- a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt +++ b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt @@ -1,6 +1,5 @@ package com.bitwarden.network.core -import com.bitwarden.network.interceptor.BaseUrlsProvider import com.bitwarden.network.model.NetworkResult import retrofit2.Call import retrofit2.CallAdapter @@ -11,10 +10,9 @@ import java.lang.reflect.Type */ internal class NetworkResultCallAdapter( private val successType: Type, - private val baseUrlsProvider: BaseUrlsProvider, ) : CallAdapter>> { override fun responseType(): Type = successType override fun adapt(call: Call): Call> = - NetworkResultCall(call, successType, baseUrlsProvider) + NetworkResultCall(call, successType) } diff --git a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterFactory.kt b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterFactory.kt index fc7c0d5e03d..7f08056af4b 100644 --- a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterFactory.kt +++ b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterFactory.kt @@ -1,6 +1,5 @@ package com.bitwarden.network.core -import com.bitwarden.network.interceptor.BaseUrlsProvider import com.bitwarden.network.model.NetworkResult import retrofit2.Call import retrofit2.CallAdapter @@ -11,9 +10,7 @@ import java.lang.reflect.Type /** * A [retrofit2.CallAdapter.Factory] for wrapping network requests into [NetworkResult]. */ -internal class NetworkResultCallAdapterFactory( - private val baseUrlsProvider: BaseUrlsProvider, -) : CallAdapter.Factory() { +internal class NetworkResultCallAdapterFactory : CallAdapter.Factory() { override fun get( returnType: Type, annotations: Array, @@ -28,10 +25,7 @@ internal class NetworkResultCallAdapterFactory( val requestType = getParameterUpperBound(0, containerType) return if (getRawType(returnType) == Call::class.java) { - NetworkResultCallAdapter( - successType = requestType, - baseUrlsProvider = baseUrlsProvider, - ) + NetworkResultCallAdapter(successType = requestType) } else { null } diff --git a/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlInterceptors.kt b/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlInterceptors.kt index 0f078df146d..dceaf9f5e54 100644 --- a/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlInterceptors.kt +++ b/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlInterceptors.kt @@ -7,7 +7,7 @@ import com.bitwarden.annotation.OmitFromCoverage */ @OmitFromCoverage internal class BaseUrlInterceptors( - val baseUrlsProvider: BaseUrlsProvider, + private val baseUrlsProvider: BaseUrlsProvider, ) { /** * An interceptor for "/api" calls. diff --git a/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt b/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt index 98da4f7d412..786cc9d3895 100644 --- a/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt @@ -23,7 +23,7 @@ import timber.log.Timber @Suppress("LongParameterList") internal class RetrofitsImpl( authTokenManager: AuthTokenManager, - private val baseUrlInterceptors: BaseUrlInterceptors, + baseUrlInterceptors: BaseUrlInterceptors, cookieInterceptor: CookieInterceptor, headersInterceptor: HeadersInterceptor, json: Json, @@ -115,9 +115,7 @@ internal class RetrofitsImpl( private val baseRetrofitBuilder: Retrofit.Builder by lazy { Retrofit.Builder() .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) - .addCallAdapterFactory( - NetworkResultCallAdapterFactory(baseUrlInterceptors.baseUrlsProvider), - ) + .addCallAdapterFactory(NetworkResultCallAdapterFactory()) .client(baseOkHttpClient) } diff --git a/network/src/main/kotlin/com/bitwarden/network/util/HostnameRedactionUtil.kt b/network/src/main/kotlin/com/bitwarden/network/util/HostnameRedactionUtil.kt index aa8e557d840..811266565fc 100644 --- a/network/src/main/kotlin/com/bitwarden/network/util/HostnameRedactionUtil.kt +++ b/network/src/main/kotlin/com/bitwarden/network/util/HostnameRedactionUtil.kt @@ -1,20 +1,48 @@ package com.bitwarden.network.util +internal val UNKNOWN_HOST_REGEX = Regex("""Unable to resolve host "([^"]+)"""") /** * List of official Bitwarden cloud hostnames that are safe to log. */ private val BITWARDEN_HOSTS = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw") +private val URL_HOST_REGEX = Regex("""https?://([^/?#\s"]+)""") + +// Matches hostnames as single-argument method calls, e.g. getCookies(vault.example.com) +private val METHOD_CALL_HOST_REGEX = + Regex("""\b\w+\(([a-zA-Z0-9][a-zA-Z0-9.\-]*\.[a-zA-Z]{2,})\)""") + +/** + * Extracts hostnames from URLs, UnknownHostException messages, and method-call log patterns + * present in this string, then redacts any self-hosted (non-Bitwarden) hostnames with + * [REDACTED_SELF_HOST]. + * + * Recognized patterns: + * - Full URLs: `https://hostname/path` + * - UnknownHostException: `Unable to resolve host "hostname"` + * - Method-call logs: `methodName(hostname)` + */ +internal fun String.redactSelfHostedHostnames(): String { + val urlHosts = URL_HOST_REGEX.findAll(this).map { it.groupValues[1] } + val exceptionHosts = UNKNOWN_HOST_REGEX.findAll(this).map { it.groupValues[1] } + val methodCallHosts = METHOD_CALL_HOST_REGEX.findAll(this).map { it.groupValues[1] } + val extractedHosts = (urlHosts + exceptionHosts + methodCallHosts) + .map { it.substringBefore(':') } // strip port if present + .filter { host -> BITWARDEN_HOSTS.none { host.endsWith(it) } } + .toSet() + return this.redactHostnamesInMessage(extractedHosts) +} + /** * Redacts hostnames in a log message by replacing bare hostnames with [REDACTED_SELF_HOST]. * * Only redacts hostnames that match [configuredHosts] AND are not official Bitwarden domains. * Preserves all Bitwarden domains (including QA/dev environments). * - * @param configuredHosts Set of hostnames from BaseUrlsProvider + * @param configuredHosts Set of hostnames to redact * @return Message with hostnames redacted as [REDACTED_SELF_HOST] */ -fun String.redactHostnamesInMessage(configuredHosts: Set): String = +internal fun String.redactHostnamesInMessage(configuredHosts: Set): String = configuredHosts.fold(this) { result, hostname -> val escapedHostname = Regex.escape(hostname) val bareHostnamePattern = Regex("""\b$escapedHostname\b""") diff --git a/network/src/test/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterTest.kt b/network/src/test/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterTest.kt index 869a6376682..0c523585477 100644 --- a/network/src/test/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/core/NetworkResultCallAdapterTest.kt @@ -1,9 +1,6 @@ package com.bitwarden.network.core -import com.bitwarden.network.interceptor.BaseUrlsProvider import com.bitwarden.network.model.NetworkResult -import io.mockk.every -import io.mockk.mockk import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -16,18 +13,12 @@ import retrofit2.http.GET class NetworkResultCallAdapterTest { - private val mockBaseUrlsProvider = mockk { - every { getBaseApiUrl() } returns "https://api.bitwarden.com" - every { getBaseIdentityUrl() } returns "https://identity.bitwarden.com" - every { getBaseEventsUrl() } returns "https://events.bitwarden.com" - } - private val server: MockWebServer = MockWebServer().apply { start() } private val testService: FakeService = Retrofit.Builder() .baseUrl(server.url("/").toString()) // add the adapter being tested - .addCallAdapterFactory(NetworkResultCallAdapterFactory(mockBaseUrlsProvider)) + .addCallAdapterFactory(NetworkResultCallAdapterFactory()) .build() .create() diff --git a/network/src/test/kotlin/com/bitwarden/network/retrofit/RetrofitsTest.kt b/network/src/test/kotlin/com/bitwarden/network/retrofit/RetrofitsTest.kt index 5c54368ed01..eaeb8a77a44 100644 --- a/network/src/test/kotlin/com/bitwarden/network/retrofit/RetrofitsTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/retrofit/RetrofitsTest.kt @@ -37,7 +37,6 @@ class RetrofitsTest { mockIntercept { isAuthInterceptorCalled = true } } private val baseUrlInterceptors = mockk { - every { baseUrlsProvider } returns mockk(relaxed = true) every { apiInterceptor } returns mockk { mockIntercept { isApiInterceptorCalled = true } } diff --git a/network/src/test/kotlin/com/bitwarden/network/util/HostnameRedactionUtilTest.kt b/network/src/test/kotlin/com/bitwarden/network/util/HostnameRedactionUtilTest.kt index 1dbc3c2cbf2..8abdd88f1e6 100644 --- a/network/src/test/kotlin/com/bitwarden/network/util/HostnameRedactionUtilTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/util/HostnameRedactionUtilTest.kt @@ -26,8 +26,8 @@ class HostnameRedactionUtilTest { @Test fun `redactHostnamesInMessage preserves Bitwarden URLs even if configured`() { - val message = "--> GET https://vault.qa.bitwarden.pw/api/sync HTTP/1.1" - val configuredHosts = setOf("vault.qa.bitwarden.pw") + val message = "--> GET https://vault.bitwarden.com/api/sync HTTP/1.1" + val configuredHosts = setOf("vault.bitwarden.com") val result = message.redactHostnamesInMessage(configuredHosts) @@ -143,4 +143,49 @@ class HostnameRedactionUtilTest { result, ) } + + @Test + fun `redactSelfHostedHostnames redacts hostname in getCookies method-call log`() { + val message = "getCookies(vault.example.com): resolved=vault.example.com, count=0" + + val result = message.redactSelfHostedHostnames() + + assertEquals( + "getCookies([REDACTED_SELF_HOST]): resolved=[REDACTED_SELF_HOST], count=0", + result, + ) + } + + @Test + fun `redactSelfHostedHostnames redacts hostname in needsBootstrap method-call log`() { + val message = "needsBootstrap(vault.example.com): false (cookieDomain=null)" + + val result = message.redactSelfHostedHostnames() + + assertEquals( + "needsBootstrap([REDACTED_SELF_HOST]): false (cookieDomain=null)", + result, + ) + } + + @Test + fun `redactSelfHostedHostnames redacts hostname in resolveHostname method-call log`() { + val message = "resolveHostname(vault.example.com): no stored config found, using original" + + val result = message.redactSelfHostedHostnames() + + assertEquals( + "resolveHostname([REDACTED_SELF_HOST]): no stored config found, using original", + result, + ) + } + + @Test + fun `redactSelfHostedHostnames preserves Bitwarden domain in method-call log`() { + val message = "getCookies(api.bitwarden.com): resolved=api.bitwarden.com, count=3" + + val result = message.redactSelfHostedHostnames() + + assertEquals(message, result) + } } diff --git a/network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt b/network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt index e6ab11bd01b..5f31899614d 100644 --- a/network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt +++ b/network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt @@ -2,7 +2,6 @@ package com.bitwarden.network.base import com.bitwarden.core.di.CoreModule import com.bitwarden.network.core.NetworkResultCallAdapterFactory -import com.bitwarden.network.interceptor.BaseUrlsProvider import okhttp3.HttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.mockwebserver.MockWebServer @@ -23,15 +22,9 @@ abstract class BaseServiceTest { protected val urlPrefix: String get() = "http://${server.hostName}:${server.port}" - private val fakeBaseUrlsProvider = object : BaseUrlsProvider { - override fun getBaseApiUrl(): String = "https://api.bitwarden.com" - override fun getBaseIdentityUrl(): String = "https://identity.bitwarden.com" - override fun getBaseEventsUrl(): String = "https://events.bitwarden.com" - } - protected val retrofit: Retrofit = Retrofit.Builder() .baseUrl(url.toString()) - .addCallAdapterFactory(NetworkResultCallAdapterFactory(fakeBaseUrlsProvider)) + .addCallAdapterFactory(NetworkResultCallAdapterFactory()) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() From 646e47e6212719a34346e2ff378aac543bc00a22 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Fri, 20 Mar 2026 16:11:46 +0000 Subject: [PATCH 6/8] Using the environment url hosts from ServerConfigRepository to redact logs --- .../FlightRecorderWriterImpl.kt | 21 +++++++-- .../network/core/NetworkResultCall.kt | 3 +- .../network/util/HostnameRedactionUtil.kt | 30 +------------ .../network/util/HostnameRedactionUtilTest.kt | 45 ------------------- 4 files changed, 21 insertions(+), 78 deletions(-) diff --git a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt index 2b815092b53..c105d84030b 100644 --- a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt +++ b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt @@ -10,7 +10,7 @@ import com.bitwarden.core.data.util.toFormattedPattern import com.bitwarden.data.datasource.disk.model.FlightRecorderDataSet import com.bitwarden.data.manager.file.FileManager import com.bitwarden.data.repository.ServerConfigRepository -import com.bitwarden.network.util.redactSelfHostedHostnames +import com.bitwarden.network.util.redactHostnamesInMessage import kotlinx.coroutines.withContext import timber.log.Timber import java.io.BufferedWriter @@ -18,6 +18,7 @@ import java.io.File import java.io.FileWriter import java.io.PrintWriter import java.io.StringWriter +import java.net.URI import java.time.Clock import java.time.Instant import kotlin.time.Duration.Companion.milliseconds @@ -35,6 +36,19 @@ internal class FlightRecorderWriterImpl( private val buildInfoManager: BuildInfoManager, private val serverConfigRepository: ServerConfigRepository, ) : FlightRecorderWriter { + private val configuredHosts: Set + get() { + val environment = serverConfigRepository.serverConfigStateFlow.value + ?.serverData?.environment ?: return emptySet() + return listOfNotNull( + environment.vaultUrl, + environment.apiUrl, + environment.identityUrl, + environment.notificationsUrl, + environment.ssoUrl, + ).mapNotNull { runCatching { URI(it).host }.getOrNull() }.toSet() + } + override suspend fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) { fileManager.delete(File(File(fileManager.logsDirectory), data.fileName)) } @@ -99,6 +113,7 @@ internal class FlightRecorderWriterImpl( val formattedTime = clock .instant() .toFormattedPattern(pattern = LOG_TIME_PATTERN, clock = clock) + val hosts = configuredHosts withContext(context = dispatcherManager.io) { runCatching { BufferedWriter(FileWriter(logFile, true)).use { bw -> @@ -110,10 +125,10 @@ internal class FlightRecorderWriterImpl( bw.append(it) } bw.append(" – ") - bw.append(message.redactSelfHostedHostnames()) + bw.append(message.redactHostnamesInMessage(hosts)) throwable?.let { bw.append(" – ") - bw.append(it.getStackTraceString().redactSelfHostedHostnames()) + bw.append(it.getStackTraceString().redactHostnamesInMessage(hosts)) } bw.newLine() } diff --git a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt index 5a4db3de1fa..2ec1350b4ca 100644 --- a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt +++ b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt @@ -1,7 +1,6 @@ package com.bitwarden.network.core import com.bitwarden.network.model.NetworkResult -import com.bitwarden.network.util.UNKNOWN_HOST_REGEX import okhttp3.Request import okio.IOException import okio.Timeout @@ -17,6 +16,8 @@ import java.lang.reflect.Type */ private const val NO_CONTENT_RESPONSE_CODE: Int = 204 +private val UNKNOWN_HOST_REGEX = Regex("""Unable to resolve host "([^"]+)"""") + /** * A [Call] for wrapping a network request into a [NetworkResult]. */ diff --git a/network/src/main/kotlin/com/bitwarden/network/util/HostnameRedactionUtil.kt b/network/src/main/kotlin/com/bitwarden/network/util/HostnameRedactionUtil.kt index 811266565fc..807ab54fccb 100644 --- a/network/src/main/kotlin/com/bitwarden/network/util/HostnameRedactionUtil.kt +++ b/network/src/main/kotlin/com/bitwarden/network/util/HostnameRedactionUtil.kt @@ -1,38 +1,10 @@ package com.bitwarden.network.util -internal val UNKNOWN_HOST_REGEX = Regex("""Unable to resolve host "([^"]+)"""") /** * List of official Bitwarden cloud hostnames that are safe to log. */ private val BITWARDEN_HOSTS = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw") -private val URL_HOST_REGEX = Regex("""https?://([^/?#\s"]+)""") - -// Matches hostnames as single-argument method calls, e.g. getCookies(vault.example.com) -private val METHOD_CALL_HOST_REGEX = - Regex("""\b\w+\(([a-zA-Z0-9][a-zA-Z0-9.\-]*\.[a-zA-Z]{2,})\)""") - -/** - * Extracts hostnames from URLs, UnknownHostException messages, and method-call log patterns - * present in this string, then redacts any self-hosted (non-Bitwarden) hostnames with - * [REDACTED_SELF_HOST]. - * - * Recognized patterns: - * - Full URLs: `https://hostname/path` - * - UnknownHostException: `Unable to resolve host "hostname"` - * - Method-call logs: `methodName(hostname)` - */ -internal fun String.redactSelfHostedHostnames(): String { - val urlHosts = URL_HOST_REGEX.findAll(this).map { it.groupValues[1] } - val exceptionHosts = UNKNOWN_HOST_REGEX.findAll(this).map { it.groupValues[1] } - val methodCallHosts = METHOD_CALL_HOST_REGEX.findAll(this).map { it.groupValues[1] } - val extractedHosts = (urlHosts + exceptionHosts + methodCallHosts) - .map { it.substringBefore(':') } // strip port if present - .filter { host -> BITWARDEN_HOSTS.none { host.endsWith(it) } } - .toSet() - return this.redactHostnamesInMessage(extractedHosts) -} - /** * Redacts hostnames in a log message by replacing bare hostnames with [REDACTED_SELF_HOST]. * @@ -42,7 +14,7 @@ internal fun String.redactSelfHostedHostnames(): String { * @param configuredHosts Set of hostnames to redact * @return Message with hostnames redacted as [REDACTED_SELF_HOST] */ -internal fun String.redactHostnamesInMessage(configuredHosts: Set): String = +fun String.redactHostnamesInMessage(configuredHosts: Set): String = configuredHosts.fold(this) { result, hostname -> val escapedHostname = Regex.escape(hostname) val bareHostnamePattern = Regex("""\b$escapedHostname\b""") diff --git a/network/src/test/kotlin/com/bitwarden/network/util/HostnameRedactionUtilTest.kt b/network/src/test/kotlin/com/bitwarden/network/util/HostnameRedactionUtilTest.kt index 8abdd88f1e6..1d98e7aa1b8 100644 --- a/network/src/test/kotlin/com/bitwarden/network/util/HostnameRedactionUtilTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/util/HostnameRedactionUtilTest.kt @@ -143,49 +143,4 @@ class HostnameRedactionUtilTest { result, ) } - - @Test - fun `redactSelfHostedHostnames redacts hostname in getCookies method-call log`() { - val message = "getCookies(vault.example.com): resolved=vault.example.com, count=0" - - val result = message.redactSelfHostedHostnames() - - assertEquals( - "getCookies([REDACTED_SELF_HOST]): resolved=[REDACTED_SELF_HOST], count=0", - result, - ) - } - - @Test - fun `redactSelfHostedHostnames redacts hostname in needsBootstrap method-call log`() { - val message = "needsBootstrap(vault.example.com): false (cookieDomain=null)" - - val result = message.redactSelfHostedHostnames() - - assertEquals( - "needsBootstrap([REDACTED_SELF_HOST]): false (cookieDomain=null)", - result, - ) - } - - @Test - fun `redactSelfHostedHostnames redacts hostname in resolveHostname method-call log`() { - val message = "resolveHostname(vault.example.com): no stored config found, using original" - - val result = message.redactSelfHostedHostnames() - - assertEquals( - "resolveHostname([REDACTED_SELF_HOST]): no stored config found, using original", - result, - ) - } - - @Test - fun `redactSelfHostedHostnames preserves Bitwarden domain in method-call log`() { - val message = "getCookies(api.bitwarden.com): resolved=api.bitwarden.com, count=3" - - val result = message.redactSelfHostedHostnames() - - assertEquals(message, result) - } } From e6560742152e933d2f151ce769e62c2a90020f74 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Mon, 27 Apr 2026 13:41:35 +0100 Subject: [PATCH 7/8] addressed PR comments to improve readibility --- .../data/manager/flightrecorder/FlightRecorderWriterImpl.kt | 4 +++- .../kotlin/com/bitwarden/network/core/NetworkResultCall.kt | 3 +-- .../com/bitwarden/network/core/NetworkResultCallAdapter.kt | 3 +-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt index c105d84030b..61007af781a 100644 --- a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt +++ b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt @@ -46,7 +46,9 @@ internal class FlightRecorderWriterImpl( environment.identityUrl, environment.notificationsUrl, environment.ssoUrl, - ).mapNotNull { runCatching { URI(it).host }.getOrNull() }.toSet() + ) + .mapNotNull { runCatching { URI(it).host }.getOrNull() } + .toSet() } override suspend fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) { diff --git a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt index 2ec1350b4ca..670342b7bff 100644 --- a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt +++ b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt @@ -28,8 +28,7 @@ internal class NetworkResultCall( ) : Call> { override fun cancel(): Unit = backingCall.cancel() - override fun clone(): Call> = - NetworkResultCall(backingCall, successType) + override fun clone(): Call> = NetworkResultCall(backingCall, successType) override fun enqueue(callback: Callback>): Unit = backingCall.enqueue( object : Callback { diff --git a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt index 590bdec3ff0..2cbd9b41e5b 100644 --- a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt +++ b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt @@ -13,6 +13,5 @@ internal class NetworkResultCallAdapter( ) : CallAdapter>> { override fun responseType(): Type = successType - override fun adapt(call: Call): Call> = - NetworkResultCall(call, successType) + override fun adapt(call: Call): Call> = NetworkResultCall(call, successType) } From 9bf1eaf8fc8a2aff137ac5107bc0cb02c7711587 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Tue, 28 Apr 2026 14:41:07 +0100 Subject: [PATCH 8/8] swapped runCatching by toUri() --- .../data/manager/flightrecorder/FlightRecorderWriterImpl.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt index 61007af781a..7746f664e81 100644 --- a/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt +++ b/data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt @@ -2,6 +2,7 @@ package com.bitwarden.data.manager.flightrecorder import android.os.Build import android.util.Log +import androidx.core.net.toUri import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.core.data.manager.BuildInfoManager import com.bitwarden.core.data.manager.dispatcher.DispatcherManager @@ -18,7 +19,6 @@ import java.io.File import java.io.FileWriter import java.io.PrintWriter import java.io.StringWriter -import java.net.URI import java.time.Clock import java.time.Instant import kotlin.time.Duration.Companion.milliseconds @@ -47,7 +47,7 @@ internal class FlightRecorderWriterImpl( environment.notificationsUrl, environment.ssoUrl, ) - .mapNotNull { runCatching { URI(it).host }.getOrNull() } + .mapNotNull { it.toUri().host } .toSet() }