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 9ba25ecab57..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,6 +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.redactHostnamesInMessage import kotlinx.coroutines.withContext import timber.log.Timber import java.io.BufferedWriter @@ -17,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 @@ -34,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)) } @@ -98,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 -> @@ -109,10 +125,10 @@ internal class FlightRecorderWriterImpl( bw.append(it) } bw.append(" – ") - bw.append(message) + bw.append(message.redactHostnamesInMessage(hosts)) throwable?.let { bw.append(" – ") - bw.append(it.getStackTraceString()) + 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 9e6d7c0f0d3..2ec1350b4ca 100644 --- a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt +++ b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt @@ -16,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]. */ @@ -26,7 +28,8 @@ 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 { @@ -67,8 +70,16 @@ 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() + + val extractedHost = message?.let { UNKNOWN_HOST_REGEX.find(it)?.groupValues?.getOrNull(1) } + + val url = if (extractedHost != null) { + "${originalUrl.protocol}://$extractedHost${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..590bdec3ff0 100644 --- a/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt +++ b/network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCallAdapter.kt @@ -13,5 +13,6 @@ 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) } 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..807ab54fccb --- /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 to redact + * @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/util/HostnameRedactionUtilTest.kt b/network/src/test/kotlin/com/bitwarden/network/util/HostnameRedactionUtilTest.kt new file mode 100644 index 00000000000..1d98e7aa1b8 --- /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.bitwarden.com/api/sync HTTP/1.1" + val configuredHosts = setOf("vault.bitwarden.com") + + 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, + ) + } +}