Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -10,6 +11,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
Expand All @@ -34,6 +36,21 @@ 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 { it.toUri().host }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be toUriOrNull()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This call the Uri.parse(string) and it only throws if the parameter string is null. On this scenario we have the mapNotNull so I believe we are safe to use as it is

.toSet()
}

override suspend fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) {
fileManager.delete(File(File(fileManager.logsDirectory), data.fileName))
}
Expand Down Expand Up @@ -98,6 +115,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 +127,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 Down Expand Up @@ -67,8 +69,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}"
Comment thread
vvolkgang marked this conversation as resolved.
}

Timber.w(this, "Network Error: $url")
return NetworkResult.Failure(this)
}
Expand Down
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