Redacting hostname when it matches a self-hosted hostname

Replacing api url with selfhosted one on toFailure at NetworkResultCall
This commit is contained in:
Andre Rosado
2026-03-09 17:44:26 +00:00
parent 81fc72d4de
commit 45c195a7d0
12 changed files with 272 additions and 34 deletions

View File

@@ -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

View File

@@ -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]""""
}
}

View File

@@ -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<T>(
private val backingCall: Call<T>,
private val successType: Type,
private val baseUrlsProvider: BaseUrlsProvider? = null,
) : 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, baseUrlsProvider)
override fun enqueue(callback: Callback<NetworkResult<T>>): Unit = backingCall.enqueue(
object : Callback<T> {
@@ -67,8 +71,32 @@ 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()
// 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)
}

View File

@@ -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<T>(
private val successType: Type,
private val baseUrlsProvider: BaseUrlsProvider,
) : 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, baseUrlsProvider)
}

View File

@@ -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<out Annotation>,
@@ -25,7 +28,10 @@ internal class NetworkResultCallAdapterFactory : CallAdapter.Factory() {
val requestType = getParameterUpperBound(0, containerType)
return if (getRawType(returnType) == Call::class.java) {
NetworkResultCallAdapter<Any>(successType = requestType)
NetworkResultCallAdapter<Any>(
successType = requestType,
baseUrlsProvider = baseUrlsProvider,
)
} else {
null
}

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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>): 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]"
}

View File

@@ -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<BaseUrlsProvider> {
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()

View File

@@ -37,6 +37,7 @@ class RetrofitsTest {
mockIntercept { isAuthInterceptorCalled = true }
}
private val baseUrlInterceptors = mockk<BaseUrlInterceptors> {
every { baseUrlsProvider } returns mockk(relaxed = true)
every { apiInterceptor } returns mockk {
mockIntercept { isApiInterceptorCalled = true }
}

View File

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

View File

@@ -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()