Skip to content

Commit b3848ff

Browse files
authored
[PM-24380] fix: Correct and redact flight recorder hostname on logs (#6633)
1 parent 3845c1f commit b3848ff

4 files changed

Lines changed: 205 additions & 4 deletions

File tree

data/src/main/kotlin/com/bitwarden/data/manager/flightrecorder/FlightRecorderWriterImpl.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.bitwarden.data.manager.flightrecorder
22

33
import android.os.Build
44
import android.util.Log
5+
import androidx.core.net.toUri
56
import com.bitwarden.annotation.OmitFromCoverage
67
import com.bitwarden.core.data.manager.BuildInfoManager
78
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
@@ -10,6 +11,7 @@ import com.bitwarden.core.data.util.toFormattedPattern
1011
import com.bitwarden.data.datasource.disk.model.FlightRecorderDataSet
1112
import com.bitwarden.data.manager.file.FileManager
1213
import com.bitwarden.data.repository.ServerConfigRepository
14+
import com.bitwarden.network.util.redactHostnamesInMessage
1315
import kotlinx.coroutines.withContext
1416
import timber.log.Timber
1517
import java.io.BufferedWriter
@@ -34,6 +36,21 @@ internal class FlightRecorderWriterImpl(
3436
private val buildInfoManager: BuildInfoManager,
3537
private val serverConfigRepository: ServerConfigRepository,
3638
) : FlightRecorderWriter {
39+
private val configuredHosts: Set<String>
40+
get() {
41+
val environment = serverConfigRepository.serverConfigStateFlow.value
42+
?.serverData?.environment ?: return emptySet()
43+
return listOfNotNull(
44+
environment.vaultUrl,
45+
environment.apiUrl,
46+
environment.identityUrl,
47+
environment.notificationsUrl,
48+
environment.ssoUrl,
49+
)
50+
.mapNotNull { it.toUri().host }
51+
.toSet()
52+
}
53+
3754
override suspend fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) {
3855
fileManager.delete(File(File(fileManager.logsDirectory), data.fileName))
3956
}
@@ -98,6 +115,7 @@ internal class FlightRecorderWriterImpl(
98115
val formattedTime = clock
99116
.instant()
100117
.toFormattedPattern(pattern = LOG_TIME_PATTERN, clock = clock)
118+
val hosts = configuredHosts
101119
withContext(context = dispatcherManager.io) {
102120
runCatching {
103121
BufferedWriter(FileWriter(logFile, true)).use { bw ->
@@ -109,10 +127,10 @@ internal class FlightRecorderWriterImpl(
109127
bw.append(it)
110128
}
111129
bw.append("")
112-
bw.append(message)
130+
bw.append(message.redactHostnamesInMessage(hosts))
113131
throwable?.let {
114132
bw.append("")
115-
bw.append(it.getStackTraceString())
133+
bw.append(it.getStackTraceString().redactHostnamesInMessage(hosts))
116134
}
117135
bw.newLine()
118136
}

network/src/main/kotlin/com/bitwarden/network/core/NetworkResultCall.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import java.lang.reflect.Type
1616
*/
1717
private const val NO_CONTENT_RESPONSE_CODE: Int = 204
1818

19+
private val UNKNOWN_HOST_REGEX = Regex("""Unable to resolve host "([^"]+)"""")
20+
1921
/**
2022
* A [Call] for wrapping a network request into a [NetworkResult].
2123
*/
@@ -67,8 +69,16 @@ internal class NetworkResultCall<T>(
6769
fun executeForResult(): NetworkResult<T> = requireNotNull(execute().body())
6870

6971
private fun Throwable.toFailure(): NetworkResult<T> {
70-
// We rebuild the URL without query params, we do not want to log those
71-
val url = backingCall.request().url.toUrl().run { "$protocol://$authority$path" }
72+
val originalUrl = backingCall.request().url.toUrl()
73+
74+
val extractedHost = message?.let { UNKNOWN_HOST_REGEX.find(it)?.groupValues?.getOrNull(1) }
75+
76+
val url = if (extractedHost != null) {
77+
"${originalUrl.protocol}://$extractedHost${originalUrl.path}"
78+
} else {
79+
"${originalUrl.protocol}://${originalUrl.authority}${originalUrl.path}"
80+
}
81+
7282
Timber.w(this, "Network Error: $url")
7383
return NetworkResult.Failure(this)
7484
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.bitwarden.network.util
2+
3+
/**
4+
* List of official Bitwarden cloud hostnames that are safe to log.
5+
*/
6+
private val BITWARDEN_HOSTS = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
7+
8+
/**
9+
* Redacts hostnames in a log message by replacing bare hostnames with [REDACTED_SELF_HOST].
10+
*
11+
* Only redacts hostnames that match [configuredHosts] AND are not official Bitwarden domains.
12+
* Preserves all Bitwarden domains (including QA/dev environments).
13+
*
14+
* @param configuredHosts Set of hostnames to redact
15+
* @return Message with hostnames redacted as [REDACTED_SELF_HOST]
16+
*/
17+
fun String.redactHostnamesInMessage(configuredHosts: Set<String>): String =
18+
configuredHosts.fold(this) { result, hostname ->
19+
val escapedHostname = Regex.escape(hostname)
20+
val bareHostnamePattern = Regex("""\b$escapedHostname\b""")
21+
bareHostnamePattern.replace(result) { hostname.redactIfSelfHosted() }
22+
}
23+
24+
private fun String.redactIfSelfHosted(): String {
25+
val isBitwardenHost = BITWARDEN_HOSTS.any { this.endsWith(it) }
26+
return if (isBitwardenHost) this else "[REDACTED_SELF_HOST]"
27+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package com.bitwarden.network.util
2+
3+
import org.junit.jupiter.api.Assertions.assertEquals
4+
import org.junit.jupiter.api.Test
5+
6+
class HostnameRedactionUtilTest {
7+
@Test
8+
fun `redactHostnamesInMessage redacts configured self-hosted URLs`() {
9+
val message = "--> GET https://vault.example.com/api/sync HTTP/1.1"
10+
val configuredHosts = setOf("vault.example.com")
11+
12+
val result = message.redactHostnamesInMessage(configuredHosts)
13+
14+
assertEquals("--> GET https://[REDACTED_SELF_HOST]/api/sync HTTP/1.1", result)
15+
}
16+
17+
@Test
18+
fun `redactHostnamesInMessage preserves non-configured URLs`() {
19+
val message = "--> GET https://vault.example.com/api/sync HTTP/1.1"
20+
val configuredHosts = setOf("api.bitwarden.com") // Different host
21+
22+
val result = message.redactHostnamesInMessage(configuredHosts)
23+
24+
assertEquals(message, result) // Unchanged - not in configured hosts
25+
}
26+
27+
@Test
28+
fun `redactHostnamesInMessage preserves Bitwarden URLs even if configured`() {
29+
val message = "--> GET https://vault.bitwarden.com/api/sync HTTP/1.1"
30+
val configuredHosts = setOf("vault.bitwarden.com")
31+
32+
val result = message.redactHostnamesInMessage(configuredHosts)
33+
34+
assertEquals(message, result) // Unchanged - Bitwarden domain preserved
35+
}
36+
37+
@Test
38+
fun `redactHostnamesInMessage redacts quoted hostnames in error messages`() {
39+
val message = """Unable to resolve host "vault.example.com": No address"""
40+
val configuredHosts = setOf("vault.example.com")
41+
42+
val result = message.redactHostnamesInMessage(configuredHosts)
43+
44+
assertEquals("""Unable to resolve host "[REDACTED_SELF_HOST]": No address""", result)
45+
}
46+
47+
@Test
48+
fun `redactHostnamesInMessage handles multiple URLs in one message`() {
49+
val message = "Redirect from https://old.corp.com to https://new.corp.com"
50+
val configuredHosts = setOf("old.corp.com", "new.corp.com")
51+
52+
val result = message.redactHostnamesInMessage(configuredHosts)
53+
54+
assertEquals(
55+
"Redirect from https://[REDACTED_SELF_HOST] to https://[REDACTED_SELF_HOST]",
56+
result,
57+
)
58+
}
59+
60+
@Test
61+
fun `redactHostnamesInMessage handles empty configured hosts`() {
62+
val message = "--> GET https://vault.example.com/api HTTP/1.1"
63+
val configuredHosts = emptySet<String>()
64+
65+
val result = message.redactHostnamesInMessage(configuredHosts)
66+
67+
assertEquals(message, result) // Unchanged - no hosts to redact
68+
}
69+
70+
@Test
71+
fun `redactHostnamesInMessage handles NetworkCookieManagerImpl getCookies pattern`() {
72+
val message = "2026-03-09 12:43:29:857 – DEBUG – NetworkCookieManagerImpl – " +
73+
"getCookies(vault.example.com): resolved=vault.example.com, count=0"
74+
val configuredHosts = setOf("vault.example.com")
75+
76+
val result = message.redactHostnamesInMessage(configuredHosts)
77+
78+
assertEquals(
79+
"2026-03-09 12:43:29:857 – DEBUG – NetworkCookieManagerImpl – " +
80+
"getCookies([REDACTED_SELF_HOST]): resolved=[REDACTED_SELF_HOST], count=0",
81+
result,
82+
)
83+
}
84+
85+
@Test
86+
fun `redactHostnamesInMessage preserves Bitwarden domains in NetworkCookieManagerImpl logs`() {
87+
val message = "2026-03-09 12:43:29:857 – DEBUG – NetworkCookieManagerImpl – " +
88+
"getCookies(vault.example.com): resolved=vault.qa.bitwarden.pw, count=0"
89+
val configuredHosts = setOf("vault.example.com", "vault.qa.bitwarden.pw")
90+
91+
val result = message.redactHostnamesInMessage(configuredHosts)
92+
93+
assertEquals(
94+
"2026-03-09 12:43:29:857 – DEBUG – NetworkCookieManagerImpl – " +
95+
"getCookies([REDACTED_SELF_HOST]): resolved=vault.qa.bitwarden.pw, count=0",
96+
result,
97+
)
98+
}
99+
100+
@Test
101+
fun `redactHostnamesInMessage handles UnknownHostException error message`() {
102+
val message = "DEBUG – BitwardenNetworkClient – <-- HTTP FAILED: " +
103+
"java.net.UnknownHostException: Unable to resolve host " +
104+
"\"vault.example.com\": No address associated with hostname."
105+
val configuredHosts = setOf("vault.example.com")
106+
107+
val result = message.redactHostnamesInMessage(configuredHosts)
108+
109+
assertEquals(
110+
"DEBUG – BitwardenNetworkClient – <-- HTTP FAILED: " +
111+
"java.net.UnknownHostException: Unable to resolve host " +
112+
"\"[REDACTED_SELF_HOST]\": No address associated with hostname.",
113+
result,
114+
)
115+
}
116+
117+
@Test
118+
fun `redactHostnamesInMessage handles needsBootstrap pattern`() {
119+
val message = "2026-03-09 12:43:29:851 – DEBUG – NetworkCookieManagerImpl – " +
120+
"needsBootstrap(vault.example.com): false (cookieDomain=null)"
121+
val configuredHosts = setOf("vault.example.com")
122+
123+
val result = message.redactHostnamesInMessage(configuredHosts)
124+
125+
assertEquals(
126+
"2026-03-09 12:43:29:851 – DEBUG – NetworkCookieManagerImpl – " +
127+
"needsBootstrap([REDACTED_SELF_HOST]): false (cookieDomain=null)",
128+
result,
129+
)
130+
}
131+
132+
@Test
133+
fun `redactHostnamesInMessage handles resolveHostname pattern`() {
134+
val message = "2026-03-09 12:43:29:855 – DEBUG – NetworkCookieManagerImpl – " +
135+
"resolveHostname(vault.example.com): no stored config found, using original"
136+
val configuredHosts = setOf("vault.example.com")
137+
138+
val result = message.redactHostnamesInMessage(configuredHosts)
139+
140+
assertEquals(
141+
"2026-03-09 12:43:29:855 – DEBUG – NetworkCookieManagerImpl – " +
142+
"resolveHostname([REDACTED_SELF_HOST]): no stored config found, using original",
143+
result,
144+
)
145+
}
146+
}

0 commit comments

Comments
 (0)