Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ 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
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
Expand All @@ -34,6 +36,19 @@ internal class FlightRecorderWriterImpl(
private val buildInfoManager: BuildInfoManager,
private val serverConfigRepository: ServerConfigRepository,
) : FlightRecorderWriter {
private val configuredHosts: Set<String>
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))
}
Expand Down Expand Up @@ -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 ->
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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].
*/
Expand All @@ -26,7 +28,8 @@ internal class NetworkResultCall<T>(
) : Call<NetworkResult<T>> {
override fun cancel(): Unit = backingCall.cancel()

override fun clone(): Call<NetworkResult<T>> = NetworkResultCall(backingCall, successType)
override fun clone(): Call<NetworkResult<T>> =
NetworkResultCall(backingCall, successType)

override fun enqueue(callback: Callback<NetworkResult<T>>): Unit = backingCall.enqueue(
object : Callback<T> {
Expand Down Expand Up @@ -67,8 +70,16 @@ internal class NetworkResultCall<T>(
fun executeForResult(): NetworkResult<T> = requireNotNull(execute().body())

private fun Throwable.toFailure(): NetworkResult<T> {
// 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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ internal class NetworkResultCallAdapter<T>(
) : CallAdapter<T, Call<NetworkResult<T>>> {

override fun responseType(): Type = successType
override fun adapt(call: Call<T>): Call<NetworkResult<T>> = NetworkResultCall(call, successType)
override fun adapt(call: Call<T>): Call<NetworkResult<T>> =
NetworkResultCall(call, successType)
}
Original file line number Diff line number Diff line change
@@ -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>): 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]"
}
Original file line number Diff line number Diff line change
@@ -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<String>()

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,
)
}
}
Loading