diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerImpl.kt index 4ab94749395..dd16dcc284b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerImpl.kt @@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager +import com.x8bit.bitwarden.data.platform.manager.CustomHeadersManager import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource @@ -28,6 +29,7 @@ import timber.log.Timber @Suppress("LongParameterList") class UserLogoutManagerImpl( private val authDiskSource: AuthDiskSource, + private val customHeadersManager: CustomHeadersManager, private val generatorDiskSource: GeneratorDiskSource, private val passwordHistoryDiskSource: PasswordHistoryDiskSource, private val pushDiskSource: PushDiskSource, @@ -49,6 +51,11 @@ class UserLogoutManagerImpl( override fun logout(userId: String, reason: LogoutReason) { authDiskSource.userState ?: return Timber.d("logout reason=$reason") + + // Clean up the account's stored custom headers while its environment data is still + // available. The removal is reference-counted, so headers still used by another account + // or the pre-auth environment remain. + customHeadersManager.removeCustomHeadersForUser(userId = userId) val isSecurityStamp = reason == LogoutReason.SecurityStamp if (isSecurityStamp) { showToast(message = BitwardenString.login_expired) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt index 5f8a2f4f44a..f5200589abe 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt @@ -28,6 +28,7 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManagerImpl import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager +import com.x8bit.bitwarden.data.platform.manager.CustomHeadersManager import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource @@ -115,6 +116,7 @@ object AuthManagerModule { @Singleton fun provideUserLogoutManager( authDiskSource: AuthDiskSource, + customHeadersManager: CustomHeadersManager, generatorDiskSource: GeneratorDiskSource, passwordHistoryDiskSource: PasswordHistoryDiskSource, pushDiskSource: PushDiskSource, @@ -127,6 +129,7 @@ object AuthManagerModule { ): UserLogoutManager = UserLogoutManagerImpl( authDiskSource = authDiskSource, + customHeadersManager = customHeadersManager, generatorDiskSource = generatorDiskSource, passwordHistoryDiskSource = passwordHistoryDiskSource, pushDiskSource = pushDiskSource, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CustomHeadersDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CustomHeadersDiskSource.kt new file mode 100644 index 00000000000..d1cc1694159 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CustomHeadersDiskSource.kt @@ -0,0 +1,23 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +/** + * Disk source for persisting the custom headers sent with requests to a self-hosted environment. + */ +interface CustomHeadersDiskSource { + + /** + * Gets the custom headers stored under the given [id]. + * + * @param id The identifier of the custom headers. + * @return The custom headers, or null if none are stored. + */ + fun getCustomHeaders(id: String): Map? + + /** + * Stores the custom [headers] under the given [id]. Pass `null` to delete the stored headers. + * + * @param id The identifier to store the custom headers under. + * @param headers The custom headers to persist, or `null` to delete. + */ + fun storeCustomHeaders(id: String, headers: Map?) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CustomHeadersDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CustomHeadersDiskSourceImpl.kt new file mode 100644 index 00000000000..ed1969e2ec5 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CustomHeadersDiskSourceImpl.kt @@ -0,0 +1,37 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import android.content.SharedPreferences +import com.bitwarden.core.data.util.decodeFromStringOrNull +import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource +import kotlinx.serialization.json.Json + +private const val CUSTOM_HEADERS_PREFIX = "customHeaders" + +/** + * Implementation of [CustomHeadersDiskSource] using encrypted SharedPreferences. + * + * The header values may contain credentials, such as Cloudflare Access service tokens, so they + * are only ever written to encrypted storage. The environment data referencing them stores only + * the opaque identifier. + */ +class CustomHeadersDiskSourceImpl( + sharedPreferences: SharedPreferences, + encryptedSharedPreferences: SharedPreferences, + private val json: Json, +) : CustomHeadersDiskSource, + BaseEncryptedDiskSource( + sharedPreferences = sharedPreferences, + encryptedSharedPreferences = encryptedSharedPreferences, + ) { + + override fun getCustomHeaders(id: String): Map? = + getEncryptedString(key = CUSTOM_HEADERS_PREFIX.appendIdentifier(id)) + ?.let { json.decodeFromStringOrNull>(it) } + + override fun storeCustomHeaders(id: String, headers: Map?) { + putEncryptedString( + key = CUSTOM_HEADERS_PREFIX.appendIdentifier(id), + value = headers?.let { json.encodeToString(it) }, + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt index 1fbabc3a6b7..fab649f23e5 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt @@ -10,6 +10,8 @@ import com.bitwarden.data.datasource.disk.di.EncryptedPreferences import com.bitwarden.data.datasource.disk.di.UnencryptedPreferences import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSourceImpl +import com.x8bit.bitwarden.data.platform.datasource.disk.CustomHeadersDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.CustomHeadersDiskSourceImpl import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSourceImpl import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource @@ -158,6 +160,18 @@ object PlatformDiskModule { sharedPreferences = sharedPreferences, ) + @Provides + @Singleton + fun provideCustomHeadersDiskSource( + @UnencryptedPreferences sharedPreferences: SharedPreferences, + @EncryptedPreferences encryptedSharedPreferences: SharedPreferences, + json: Json, + ): CustomHeadersDiskSource = CustomHeadersDiskSourceImpl( + sharedPreferences = sharedPreferences, + encryptedSharedPreferences = encryptedSharedPreferences, + json = json, + ) + @Provides @Singleton fun provideCookieDiskSource( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt index 406fb7de784..4924f46883e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt @@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_CL import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_CLIENT_VERSION import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_USER_AGENT import com.x8bit.bitwarden.data.platform.manager.CertificateManager +import com.x8bit.bitwarden.data.platform.manager.CustomHeadersManager import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager import dagger.Module @@ -64,6 +65,7 @@ object PlatformNetworkModule { baseUrlsProvider: BaseUrlsProvider, authDiskSource: AuthDiskSource, certificateManager: CertificateManager, + customHeadersManager: CustomHeadersManager, buildInfoManager: BuildInfoManager, networkCookieManager: NetworkCookieManager, networkPermissionManager: NetworkPermissionManager, @@ -79,6 +81,7 @@ object PlatformNetworkModule { authTokenProvider = authTokenManager, baseUrlsProvider = baseUrlsProvider, certificateProvider = certificateManager, + customHeadersProvider = customHeadersManager, enableHttpBodyLogging = buildInfoManager.isDevBuild, cookieProvider = networkCookieManager, permissionProvider = networkPermissionManager, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/CustomHeadersManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/CustomHeadersManager.kt new file mode 100644 index 00000000000..cbd4031c7a3 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/CustomHeadersManager.kt @@ -0,0 +1,45 @@ +package com.x8bit.bitwarden.data.platform.manager + +import com.bitwarden.network.provider.CustomHeadersProvider + +/** + * Responsible for managing the custom headers sent with requests to a self-hosted environment. + * + * The header values are kept in encrypted storage under an opaque identifier; only that + * identifier is persisted with the environment data. + */ +interface CustomHeadersManager : CustomHeadersProvider { + + /** + * Gets the custom headers stored under the given [id]. + * + * @param id The identifier of the custom headers. + * @return The custom headers, or null if none are stored. + */ + fun getStoredCustomHeaders(id: String): Map? + + /** + * Stores the custom [headers] and returns the identifier under which they were saved. + * + * @param headers The custom headers to store. + * @return The identifier of the stored custom headers. + */ + fun saveCustomHeaders(headers: Map): String + + /** + * Removes the custom headers with the given [id] from storage if no environment still + * references them. + * + * @param id The identifier of the custom headers to remove. + */ + fun removeCustomHeaders(id: String) + + /** + * Removes the custom headers referenced by the environment of the account with the given + * [userId] if no other environment still references them. This must be called while the + * account's data is still available. + * + * @param userId The user ID of the account being removed. + */ + fun removeCustomHeadersForUser(userId: String) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/CustomHeadersManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/CustomHeadersManagerImpl.kt new file mode 100644 index 00000000000..b4f4c8cc0b7 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/CustomHeadersManagerImpl.kt @@ -0,0 +1,90 @@ +package com.x8bit.bitwarden.data.platform.manager + +import com.bitwarden.data.repository.util.baseApiUrl +import com.bitwarden.data.repository.util.baseEventsUrl +import com.bitwarden.data.repository.util.baseIconUrl +import com.bitwarden.data.repository.util.baseIdentityUrl +import com.bitwarden.data.repository.util.baseWebVaultUrlOrNull +import com.bitwarden.data.repository.util.toEnvironmentUrls +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.CustomHeadersDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource +import com.x8bit.bitwarden.data.platform.util.toUriOrNull +import java.util.UUID + +/** + * Default implementation of [CustomHeadersManager]. + */ +class CustomHeadersManagerImpl( + private val authDiskSource: AuthDiskSource, + private val customHeadersDiskSource: CustomHeadersDiskSource, + private val environmentDiskSource: EnvironmentDiskSource, +) : CustomHeadersManager { + + override fun getCustomHeaders(url: String): Map { + val environmentUrlData = environmentDiskSource.preAuthEnvironmentUrlData + val id = environmentUrlData?.customHeadersId ?: return emptyMap() + val requestUri = url.toUriOrNull() ?: return emptyMap() + val requestHost = requestUri.host ?: return emptyMap() + + // Scope the headers to the environment's URLs, matching on both scheme and host, so + // their values, which may contain credentials, are never sent to third parties or + // downgraded to a cleartext connection. + val environment = environmentUrlData.toEnvironmentUrls() + val isEnvironmentUrl = listOfNotNull( + environment.baseApiUrl, + environment.baseEventsUrl, + environment.baseIconUrl, + environment.baseIdentityUrl, + environment.baseWebVaultUrlOrNull, + ) + .mapNotNull { it.toUriOrNull() } + .any { it.scheme == requestUri.scheme && it.host == requestHost } + if (!isEnvironmentUrl) return emptyMap() + + return customHeadersDiskSource.getCustomHeaders(id = id).orEmpty() + } + + override fun getStoredCustomHeaders(id: String): Map? = + customHeadersDiskSource.getCustomHeaders(id = id) + + override fun saveCustomHeaders(headers: Map): String { + val id = UUID.randomUUID().toString() + customHeadersDiskSource.storeCustomHeaders(id = id, headers = headers) + return id + } + + override fun removeCustomHeaders(id: String) { + if (isIdInUse(id = id)) return + customHeadersDiskSource.storeCustomHeaders(id = id, headers = null) + } + + override fun removeCustomHeadersForUser(userId: String) { + val id = authDiskSource + .userState + ?.accounts + ?.get(userId) + ?.settings + ?.environmentUrlData + ?.customHeadersId + ?: return + if (isIdInUse(id = id, excludedUserId = userId)) return + customHeadersDiskSource.storeCustomHeaders(id = id, headers = null) + } + + /** + * Returns whether the pre-auth environment or any account's environment, other than the + * [excludedUserId] account's, still references the given custom headers [id]. + */ + private fun isIdInUse(id: String, excludedUserId: String? = null): Boolean { + if (environmentDiskSource.preAuthEnvironmentUrlData?.customHeadersId == id) return true + return authDiskSource + .userState + ?.accounts + .orEmpty() + .any { (userId, account) -> + userId != excludedUserId && + account.settings.environmentUrlData?.customHeadersId == id + } + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt index d66908241ab..5227bf9d216 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt @@ -26,6 +26,8 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnab import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.CustomHeadersDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource @@ -42,6 +44,8 @@ import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManagerImpl import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManagerImpl +import com.x8bit.bitwarden.data.platform.manager.CustomHeadersManager +import com.x8bit.bitwarden.data.platform.manager.CustomHeadersManagerImpl import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManagerImpl import com.x8bit.bitwarden.data.platform.manager.DebugMenuFeatureFlagManagerImpl @@ -405,6 +409,18 @@ object PlatformManagerModule { environmentRepository = environmentRepository, ) + @Provides + @Singleton + fun provideCustomHeadersManager( + authDiskSource: AuthDiskSource, + customHeadersDiskSource: CustomHeadersDiskSource, + environmentDiskSource: EnvironmentDiskSource, + ): CustomHeadersManager = CustomHeadersManagerImpl( + authDiskSource = authDiskSource, + customHeadersDiskSource = customHeadersDiskSource, + environmentDiskSource = environmentDiskSource, + ) + @Provides @Singleton fun provideAppResumeManager( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/EnvironmentRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/EnvironmentRepositoryImpl.kt index f2f254c1d45..cb44c0f2bb7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/EnvironmentRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/EnvironmentRepositoryImpl.kt @@ -6,6 +6,7 @@ import com.bitwarden.data.repository.util.toEnvironmentUrls import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource +import com.x8bit.bitwarden.data.platform.manager.CustomHeadersManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -22,6 +23,7 @@ import timber.log.Timber class EnvironmentRepositoryImpl( private val environmentDiskSource: EnvironmentDiskSource, private val authDiskSource: AuthDiskSource, + private val customHeadersManager: CustomHeadersManager, dispatcherManager: DispatcherManager, ) : EnvironmentRepository { @@ -32,7 +34,14 @@ class EnvironmentRepositoryImpl( .preAuthEnvironmentUrlData .toEnvironmentUrlsOrDefault() set(value) { + val droppedCustomHeadersId = environmentDiskSource + .preAuthEnvironmentUrlData + ?.customHeadersId + ?.takeUnless { it == value.environmentUrlData.customHeadersId } environmentDiskSource.preAuthEnvironmentUrlData = value.environmentUrlData + // Replacing the environment drops its reference to any stored custom headers. The + // removal is reference-counted, so headers still used by an account remain. + droppedCustomHeadersId?.let { customHeadersManager.removeCustomHeaders(id = it) } } override val environmentStateFlow: StateFlow = environmentDiskSource diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt index 40460f67f37..a555733c55b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt @@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import com.x8bit.bitwarden.data.platform.manager.CustomHeadersManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.repository.AuthenticatorBridgeRepository import com.x8bit.bitwarden.data.platform.repository.AuthenticatorBridgeRepositoryImpl @@ -54,11 +55,13 @@ object PlatformRepositoryModule { fun provideEnvironmentRepository( environmentDiskSource: EnvironmentDiskSource, authDiskSource: AuthDiskSource, + customHeadersManager: CustomHeadersManager, dispatcherManager: DispatcherManager, ): EnvironmentRepository = EnvironmentRepositoryImpl( environmentDiskSource = environmentDiskSource, authDiskSource = authDiskSource, + customHeadersManager = customHeadersManager, dispatcherManager = dispatcherManager, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt index f7a5d37db87..89e550648f5 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -32,6 +33,7 @@ import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton import com.bitwarden.ui.platform.components.button.BitwardenTextButton import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.bitwarden.ui.platform.components.field.BitwardenPasswordField import com.bitwarden.ui.platform.components.field.BitwardenTextField import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.bitwarden.ui.platform.components.model.CardStyle @@ -43,6 +45,7 @@ import com.bitwarden.ui.platform.composition.LocalIntentManager import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenClientCertificateDialog import com.x8bit.bitwarden.ui.platform.composition.LocalKeyChainManager import com.x8bit.bitwarden.ui.platform.manager.keychain.KeyChainManager @@ -338,8 +341,121 @@ fun EnvironmentScreen( .standardHorizontalMargin() .testTag("ChooseSystemCertificateButton"), ) + Spacer(modifier = Modifier.height(height = 16.dp)) + + BitwardenListHeaderText( + label = stringResource(id = BitwardenString.custom_headers), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(height = 8.dp)) + + state.customHeaders.forEach { header -> + CustomHeaderRow( + header = header, + onNameChange = { + viewModel.trySendAction( + EnvironmentAction.HeaderNameChange(id = header.id, name = it), + ) + }, + onValueChange = { + viewModel.trySendAction( + EnvironmentAction.HeaderValueChange(id = header.id, value = it), + ) + }, + onValueVisibilityChange = { + viewModel.trySendAction( + EnvironmentAction.HeaderValueVisibilityChange( + id = header.id, + isVisible = it, + ), + ) + }, + onRemoveClick = { + viewModel.trySendAction( + EnvironmentAction.RemoveHeaderClick(id = header.id), + ) + }, + ) + Spacer(modifier = Modifier.height(height = 8.dp)) + } + + BitwardenOutlinedButton( + label = stringResource(id = BitwardenString.add_header), + onClick = { viewModel.trySendAction(EnvironmentAction.AddHeaderClick) }, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .testTag("AddHeaderButton"), + ) + Spacer(modifier = Modifier.height(height = 8.dp)) + + Text( + text = stringResource( + id = BitwardenString.custom_headers_are_sent_with_every_request_to_your_server, + ), + style = BitwardenTheme.typography.bodySmall, + color = BitwardenTheme.colorScheme.text.secondary, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(height = 16.dp)) Spacer(modifier = Modifier.navigationBarsPadding()) } } } + +/** + * Displays the editable name/value fields and remove button for a single custom header. + */ +@Composable +private fun CustomHeaderRow( + header: EnvironmentState.CustomHeaderField, + onNameChange: (String) -> Unit, + onValueChange: (String) -> Unit, + onValueVisibilityChange: (Boolean) -> Unit, + onRemoveClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + BitwardenTextField( + label = stringResource(id = BitwardenString.name), + value = header.name, + onValueChange = onNameChange, + textFieldTestTag = "HeaderNameEntry", + cardStyle = CardStyle.Top(), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + + BitwardenPasswordField( + label = stringResource(id = BitwardenString.value), + value = header.value, + showPassword = header.isValueVisible, + showPasswordChange = onValueVisibilityChange, + onValueChange = onValueChange, + showPasswordTestTag = "HeaderValueVisibilityToggle", + passwordFieldTestTag = "HeaderValueEntry", + cardStyle = CardStyle.Middle(), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + + BitwardenOutlinedButton( + label = stringResource(id = BitwardenString.remove_header), + onClick = onRemoveClick, + cardStyle = CardStyle.Bottom, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .testTag("RemoveHeaderButton"), + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt index 0f744add32a..067eccc1d50 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt @@ -21,6 +21,7 @@ import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.data.platform.datasource.disk.model.MutualTlsKeyHost import com.x8bit.bitwarden.data.platform.manager.CertificateManager +import com.x8bit.bitwarden.data.platform.manager.CustomHeadersManager import com.x8bit.bitwarden.data.platform.manager.model.ImportPrivateKeyResult import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.ui.platform.manager.keychain.model.PrivateKeyAliasSelectionResult @@ -33,44 +34,66 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import java.util.UUID import javax.inject.Inject private const val KEY_STATE = "state" +private const val HEADER_NAME_ALLOWED_SPECIAL_CHARS = "!#$%&'*+-.^_`|~" + /** * View model for the self-hosted/custom environment screen. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel class EnvironmentViewModel @Inject constructor( private val environmentRepository: EnvironmentRepository, private val fileManager: FileManager, private val certificateManager: CertificateManager, + private val customHeadersManager: CustomHeadersManager, private val snackbarRelayManager: SnackbarRelayManager, private val savedStateHandle: SavedStateHandle, buildInfoManager: BuildInfoManager, ) : BaseViewModel( - initialState = savedStateHandle[KEY_STATE] ?: run { - val environmentUrlData = when (val environment = environmentRepository.environment) { - is Environment.Prod -> EnvironmentUrlDataJson(base = "") - is Environment.SelfHosted -> environment.environmentUrlData - } - val keyUri = environmentUrlData.keyUri?.toUri() - val keyAlias = keyUri?.path?.trim('/').orEmpty() - val keyHost = MutualTlsKeyHost.entries.find { it.name == keyUri?.authority } - EnvironmentState( - serverUrl = environmentUrlData.base, - webVaultServerUrl = environmentUrlData.webVault.orEmpty(), - apiServerUrl = environmentUrlData.api.orEmpty(), - identityServerUrl = environmentUrlData.identity.orEmpty(), - iconsServerUrl = environmentUrlData.icon.orEmpty(), - keyAlias = keyAlias, - keyHost = keyHost, - dialog = null, - isRelease = buildInfoManager.isReleaseBuild, - ) - }, + initialState = savedStateHandle + .get(KEY_STATE) + ?.withRestoredCustomHeaderValues(customHeadersManager = customHeadersManager) + ?: run { + val environmentUrlData = when (val environment = environmentRepository.environment) { + is Environment.Prod -> EnvironmentUrlDataJson(base = "") + is Environment.SelfHosted -> environment.environmentUrlData + } + val keyUri = environmentUrlData.keyUri?.toUri() + val keyAlias = keyUri?.path?.trim('/').orEmpty() + val keyHost = MutualTlsKeyHost.entries.find { it.name == keyUri?.authority } + val customHeadersId = environmentUrlData.customHeadersId + val customHeaders = customHeadersId + ?.let { customHeadersManager.getStoredCustomHeaders(id = it) } + .orEmpty() + .toSortedMap() + .map { (name, value) -> + EnvironmentState.CustomHeaderField( + id = UUID.randomUUID().toString(), + name = name, + value = value, + ) + } + EnvironmentState( + serverUrl = environmentUrlData.base, + webVaultServerUrl = environmentUrlData.webVault.orEmpty(), + apiServerUrl = environmentUrlData.api.orEmpty(), + identityServerUrl = environmentUrlData.identity.orEmpty(), + iconsServerUrl = environmentUrlData.icon.orEmpty(), + keyAlias = keyAlias, + customHeaders = customHeaders, + customHeadersId = customHeadersId, + keyHost = keyHost, + dialog = null, + isRelease = buildInfoManager.isReleaseBuild, + ) + }, ) { init { @@ -88,6 +111,14 @@ class EnvironmentViewModel @Inject constructor( is EnvironmentAction.ApiServerUrlChange -> handleApiServerUrlChangeAction(action) is EnvironmentAction.IdentityServerUrlChange -> handleIdentityServerUrlChangeAction(action) is EnvironmentAction.IconsServerUrlChange -> handleIconsServerUrlChangeAction(action) + is EnvironmentAction.AddHeaderClick -> handleAddHeaderClickAction() + is EnvironmentAction.HeaderNameChange -> handleHeaderNameChangeAction(action) + is EnvironmentAction.HeaderValueChange -> handleHeaderValueChangeAction(action) + is EnvironmentAction.HeaderValueVisibilityChange -> { + handleHeaderValueVisibilityChangeAction(action) + } + + is EnvironmentAction.RemoveHeaderClick -> handleRemoveHeaderClickAction(action) is EnvironmentAction.ImportCertificateClick -> handleImportCertificateClick() is EnvironmentAction.ImportCertificateFilePickerResultReceive -> { handleCertificateFilePickerResultReceive(action) @@ -146,12 +177,20 @@ class EnvironmentViewModel @Inject constructor( return } + if (!state.customHeadersAreAllValid) { + showErrorDialog( + message = BitwardenString.one_or_more_custom_headers_are_invalid.asText(), + ) + return + } + // Ensure all non-null/non-empty values have "http(s)://" prefixed. val updatedServerUrl = state.serverUrl.prefixHttpsIfNecessaryOrNull() ?: "" val updatedWebVaultServerUrl = state.webVaultServerUrl.prefixHttpsIfNecessaryOrNull() val updatedApiServerUrl = state.apiServerUrl.prefixHttpsIfNecessaryOrNull() val updatedIdentityServerUrl = state.identityServerUrl.prefixHttpsIfNecessaryOrNull() val updatedIconsServerUrl = state.iconsServerUrl.prefixHttpsIfNecessaryOrNull() + val updatedCustomHeadersId = saveCustomHeaders() environmentRepository.environment = Environment.SelfHosted( environmentUrlData = EnvironmentUrlDataJson( base = updatedServerUrl, @@ -160,9 +199,12 @@ class EnvironmentViewModel @Inject constructor( icon = updatedIconsServerUrl, webVault = updatedWebVaultServerUrl, keyUri = state.keyUri, + customHeadersId = updatedCustomHeadersId, ), ) + mutableStateFlow.update { it.copy(customHeadersId = updatedCustomHeadersId) } + snackbarRelayManager.sendSnackbarData( data = BitwardenSnackbarData(message = BitwardenString.environment_saved.asText()), relay = SnackbarRelay.ENVIRONMENT_SAVED, @@ -253,6 +295,74 @@ class EnvironmentViewModel @Inject constructor( } } + private fun handleAddHeaderClickAction() { + mutableStateFlow.update { + it.copy( + customHeaders = it.customHeaders + + EnvironmentState.CustomHeaderField(id = UUID.randomUUID().toString()), + ) + } + } + + private fun handleHeaderNameChangeAction( + action: EnvironmentAction.HeaderNameChange, + ) { + updateHeaderField(id = action.id) { it.copy(name = action.name) } + } + + private fun handleHeaderValueChangeAction( + action: EnvironmentAction.HeaderValueChange, + ) { + updateHeaderField(id = action.id) { it.copy(value = action.value) } + } + + private fun handleHeaderValueVisibilityChangeAction( + action: EnvironmentAction.HeaderValueVisibilityChange, + ) { + updateHeaderField(id = action.id) { it.copy(isValueVisible = action.isVisible) } + } + + private fun handleRemoveHeaderClickAction( + action: EnvironmentAction.RemoveHeaderClick, + ) { + mutableStateFlow.update { state -> + state.copy(customHeaders = state.customHeaders.filterNot { it.id == action.id }) + } + } + + private fun updateHeaderField( + id: String, + transform: (EnvironmentState.CustomHeaderField) -> EnvironmentState.CustomHeaderField, + ) { + mutableStateFlow.update { state -> + state.copy( + customHeaders = state.customHeaders.map { + if (it.id == id) transform(it) else it + }, + ) + } + } + + /** + * Persists the edited custom headers and returns the identifier to store in the environment + * URLs, reusing the existing identifier when the headers are unchanged. + */ + private fun saveCustomHeaders(): String? { + val headers = state.customHeadersMap + val previousId = state.customHeadersId + + if (headers.isEmpty()) return null + + return if ( + previousId != null && + customHeadersManager.getStoredCustomHeaders(id = previousId) == headers + ) { + previousId + } else { + customHeadersManager.saveCustomHeaders(headers = headers) + } + } + private fun handleImportCertificateClick() { sendEvent(EnvironmentEvent.ShowCertificateImportFileChooser) } @@ -448,6 +558,8 @@ data class EnvironmentState( val identityServerUrl: String, val iconsServerUrl: String, val keyAlias: String, + val customHeaders: List, + val customHeadersId: String?, val dialog: DialogState?, // internal private val keyHost: MutualTlsKeyHost?, @@ -467,6 +579,51 @@ data class EnvironmentState( get() = "cert://$keyHost/$keyAlias" .takeUnless { keyHost == null || keyAlias.isEmpty() } + /** + * The custom header fields as a map of trimmed, non-empty name/value pairs. + */ + val customHeadersMap: Map + get() = customHeaders + .mapNotNull { field -> + val name = field.name.trim() + val value = field.value.trim() + (name to value).takeUnless { name.isEmpty() || value.isEmpty() } + } + .toMap() + + /** + * Whether the custom header fields that would be saved all have valid names and values and + * no duplicate names. + */ + val customHeadersAreAllValid: Boolean + get() { + val fields = customHeaders + .map { it.name.trim() to it.value.trim() } + .filterNot { (name, value) -> name.isEmpty() && value.isEmpty() } + return fields.all { (name, value) -> + name.isValidHeaderName && value.isValidHeaderValue + } && + fields.distinctBy { (name, _) -> name }.size == fields.size + } + + /** + * A single editable custom header name/value pair. + * + * @property id A unique identifier for the field, used to target edits and removals. + * @property name The header name. + * @property value The header value. Not persisted in the saved state, since it may contain + * a credential; stored values are restored from encrypted storage instead. + * @property isValueVisible Whether the header value, which may contain a credential, is + * shown in plain text. + */ + @Parcelize + data class CustomHeaderField( + val id: String, + val name: String = "", + @IgnoredOnParcel val value: String = "", + val isValueVisible: Boolean = false, + ) : Parcelable + /** * Models the dialog states of the environment screen. */ @@ -635,6 +792,42 @@ sealed class EnvironmentAction { val iconsServerUrl: String, ) : EnvironmentAction() + /** + * User clicked the add custom header button. + */ + data object AddHeaderClick : EnvironmentAction() + + /** + * Indicates that the name of a custom header field has changed. + */ + data class HeaderNameChange( + val id: String, + val name: String, + ) : EnvironmentAction() + + /** + * Indicates that the value of a custom header field has changed. + */ + data class HeaderValueChange( + val id: String, + val value: String, + ) : EnvironmentAction() + + /** + * Indicates that the visibility of a custom header field's value has changed. + */ + data class HeaderValueVisibilityChange( + val id: String, + val isVisible: Boolean, + ) : EnvironmentAction() + + /** + * User clicked the remove button of a custom header field. + */ + data class RemoveHeaderClick( + val id: String, + ) : EnvironmentAction() + /** * Indicates that the certificate file selection result was received. */ @@ -680,3 +873,39 @@ sealed class EnvironmentAction { ) : Internal() } } + +/** + * Restores the custom header values from encrypted storage, since they may contain credentials + * and are excluded from the saved state. + */ +private fun EnvironmentState.withRestoredCustomHeaderValues( + customHeadersManager: CustomHeadersManager, +): EnvironmentState { + if (customHeaders.none { it.value.isEmpty() }) return this + val storedHeaders = customHeadersId + ?.let { customHeadersManager.getStoredCustomHeaders(id = it) } + .orEmpty() + return copy( + customHeaders = customHeaders.map { field -> + field.takeUnless { it.value.isEmpty() } + ?: field.copy(value = storedHeaders[field.name].orEmpty()) + }, + ) +} + +/** + * Whether this is a valid HTTP header name, meaning a non-empty RFC 7230 token. + */ +private val String.isValidHeaderName: Boolean + get() = isNotEmpty() && + all { it.isAsciiLetterOrDigit() || it in HEADER_NAME_ALLOWED_SPECIAL_CHARS } + +/** + * Whether this is a valid HTTP header value, meaning non-empty and containing only printable + * ASCII characters or tabs, which is what OkHttp accepts. + */ +private val String.isValidHeaderValue: Boolean + get() = isNotEmpty() && all { it == '\t' || it.code in ' '.code..'~'.code } + +private fun Char.isAsciiLetterOrDigit(): Boolean = + this in 'a'..'z' || this in 'A'..'Z' || this in '0'..'9' diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModule.kt index 9ca5654b411..2de9692225f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModule.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.glide import android.content.Context import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.network.interceptor.CustomHeadersInterceptor import com.bitwarden.network.ssl.createMtlsOkHttpClient import com.bumptech.glide.Glide import com.bumptech.glide.Registry @@ -10,6 +11,7 @@ import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.module.AppGlideModule import com.x8bit.bitwarden.data.platform.manager.CertificateManager +import com.x8bit.bitwarden.data.platform.manager.CustomHeadersManager import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -41,6 +43,11 @@ class BitwardenAppGlideModule : AppGlideModule() { */ fun certificateManager(): CertificateManager + /** + * Provides access to the [CustomHeadersManager] for custom header authentication. + */ + fun customHeadersManager(): CustomHeadersManager + /** * Provides access to the [NetworkCookieManager] for cookie-based authentication. */ @@ -54,16 +61,18 @@ class BitwardenAppGlideModule : AppGlideModule() { entryPoint = BitwardenGlideEntryPoint::class.java, ) val certificateManager = entryPoint.certificateManager() + val customHeadersManager = entryPoint.customHeadersManager() val networkCookieManager = entryPoint.networkCookieManager() - // Build OkHttpClient with mTLS and cookie support + // Build OkHttpClient with mTLS, cookie, and custom header support val client = certificateManager .createMtlsOkHttpClient() .newBuilder() .addNetworkInterceptor(GlideCookieInterceptor(networkCookieManager)) + .addNetworkInterceptor(CustomHeadersInterceptor(customHeadersManager)) .build() - // Register OkHttpUrlLoader that uses our mTLS + cookie OkHttpClient + // Register OkHttpUrlLoader that uses our mTLS + cookie + custom header OkHttpClient registry.replace( GlideUrl::class.java, InputStream::class.java, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerTest.kt index 12050c61a97..3e7987077f3 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerTest.kt @@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager +import com.x8bit.bitwarden.data.platform.manager.CustomHeadersManager import com.x8bit.bitwarden.data.platform.manager.model.UnregisterExportResult import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource @@ -36,6 +37,9 @@ class UserLogoutManagerTest { every { userState = any() } just runs every { clearData(any()) } just runs } + private val customHeadersManager: CustomHeadersManager = mockk { + every { removeCustomHeadersForUser(any()) } just runs + } private val generatorDiskSource: GeneratorDiskSource = mockk { every { clearData(any()) } just runs } @@ -67,6 +71,7 @@ class UserLogoutManagerTest { private val userLogoutManager: UserLogoutManager = UserLogoutManagerImpl( authDiskSource = authDiskSource, + customHeadersManager = customHeadersManager, generatorDiskSource = generatorDiskSource, passwordHistoryDiskSource = passwordHistoryDiskSource, pushDiskSource = pushDiskSource, @@ -93,6 +98,28 @@ class UserLogoutManagerTest { assertDataCleared(userId = userId) } + @Test + fun `logout should remove the custom headers associated with the given user`() { + every { authDiskSource.userState } returns SINGLE_USER_STATE_1 + + userLogoutManager.logout(userId = USER_ID_1, reason = LogoutReason.Timeout) + + verify(exactly = 1) { + customHeadersManager.removeCustomHeadersForUser(userId = USER_ID_1) + } + } + + @Test + fun `logout with no user state should do nothing`() { + every { authDiskSource.userState } returns null + + userLogoutManager.logout(userId = USER_ID_1, reason = LogoutReason.Timeout) + + verify(exactly = 0) { + customHeadersManager.removeCustomHeadersForUser(userId = any()) + } + } + @Suppress("MaxLineLength") @Test fun `logout for multiple accounts should clear data associated with the given user and change to the new active user`() { @@ -185,6 +212,9 @@ class UserLogoutManagerTest { assertDataCleared(userId = userId) + verify(exactly = 0) { + customHeadersManager.removeCustomHeadersForUser(userId = any()) + } verify(exactly = 1) { settingsDiskSource.storeVaultTimeoutInMinutes( userId = userId, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CustomHeadersDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CustomHeadersDiskSourceTest.kt new file mode 100644 index 00000000000..2a7af1c9b46 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CustomHeadersDiskSourceTest.kt @@ -0,0 +1,76 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import com.bitwarden.core.di.CoreModule +import com.bitwarden.data.datasource.disk.base.FakeSharedPreferences +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class CustomHeadersDiskSourceTest { + private val fakeEncryptedSharedPreferences = FakeSharedPreferences() + private val fakeSharedPreferences = FakeSharedPreferences() + private val json = CoreModule.providesJson(buildInfoManager = mockk(relaxed = true)) + + private val customHeadersDiskSource: CustomHeadersDiskSource = CustomHeadersDiskSourceImpl( + sharedPreferences = fakeSharedPreferences, + encryptedSharedPreferences = fakeEncryptedSharedPreferences, + json = json, + ) + + @Test + fun `getCustomHeaders should return null when no headers exist`() { + assertNull(customHeadersDiskSource.getCustomHeaders(id = "unknownId")) + } + + @Test + fun `storeCustomHeaders should persist headers and getCustomHeaders should retrieve them`() { + val id = "headersId" + val headers = mapOf( + "CF-Access-Client-Id" to "clientId", + "CF-Access-Client-Secret" to "clientSecret", + ) + + customHeadersDiskSource.storeCustomHeaders(id = id, headers = headers) + + assertEquals(headers, customHeadersDiskSource.getCustomHeaders(id = id)) + } + + @Test + fun `storeCustomHeaders should update existing headers`() { + val id = "headersId" + val initialHeaders = mapOf("X-Custom-Header" to "initialValue") + val updatedHeaders = mapOf("X-Custom-Header" to "updatedValue") + + customHeadersDiskSource.storeCustomHeaders(id = id, headers = initialHeaders) + customHeadersDiskSource.storeCustomHeaders(id = id, headers = updatedHeaders) + + assertEquals(updatedHeaders, customHeadersDiskSource.getCustomHeaders(id = id)) + } + + @Test + fun `storeCustomHeaders with null should remove stored headers`() { + val id = "headersId" + val headers = mapOf("X-Custom-Header" to "value") + + customHeadersDiskSource.storeCustomHeaders(id = id, headers = headers) + customHeadersDiskSource.storeCustomHeaders(id = id, headers = null) + + assertNull(customHeadersDiskSource.getCustomHeaders(id = id)) + } + + @Test + fun `storeCustomHeaders with null should not affect other ids`() { + val id1 = "headersId1" + val id2 = "headersId2" + val headers1 = mapOf("X-Custom-Header-A" to "1") + val headers2 = mapOf("X-Custom-Header-B" to "2") + + customHeadersDiskSource.storeCustomHeaders(id = id1, headers = headers1) + customHeadersDiskSource.storeCustomHeaders(id = id2, headers = headers2) + customHeadersDiskSource.storeCustomHeaders(id = id1, headers = null) + + assertNull(customHeadersDiskSource.getCustomHeaders(id = id1)) + assertEquals(headers2, customHeadersDiskSource.getCustomHeaders(id = id2)) + } +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/CustomHeadersManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/CustomHeadersManagerTest.kt new file mode 100644 index 00000000000..4c279e03ddc --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/CustomHeadersManagerTest.kt @@ -0,0 +1,297 @@ +package com.x8bit.bitwarden.data.platform.manager + +import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson +import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.CustomHeadersDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.FakeEnvironmentDiskSource +import com.x8bit.bitwarden.data.vault.repository.model.createMockAccountJson +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class CustomHeadersManagerTest { + + private val fakeAuthDiskSource = FakeAuthDiskSource() + private val fakeEnvironmentDiskSource = FakeEnvironmentDiskSource() + private val customHeadersDiskSource: CustomHeadersDiskSource = mockk { + every { storeCustomHeaders(id = any(), headers = any()) } just runs + } + + private val customHeadersManager: CustomHeadersManager = CustomHeadersManagerImpl( + authDiskSource = fakeAuthDiskSource, + customHeadersDiskSource = customHeadersDiskSource, + environmentDiskSource = fakeEnvironmentDiskSource, + ) + + @Test + fun `getCustomHeaders by url should return empty map when there is no environment data`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = null + + val result = customHeadersManager.getCustomHeaders(url = SELF_HOSTED_ICON_URL) + + assertEquals(emptyMap(), result) + verify(exactly = 0) { customHeadersDiskSource.getCustomHeaders(id = any()) } + } + + @Suppress("MaxLineLength") + @Test + fun `getCustomHeaders by url should return empty map when the environment has no custom headers id`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = SELF_HOSTED_ENVIRONMENT.copy( + customHeadersId = null, + ) + + val result = customHeadersManager.getCustomHeaders(url = SELF_HOSTED_ICON_URL) + + assertEquals(emptyMap(), result) + verify(exactly = 0) { customHeadersDiskSource.getCustomHeaders(id = any()) } + } + + @Suppress("MaxLineLength") + @Test + fun `getCustomHeaders by url should return empty map when the url host is not an environment host`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = SELF_HOSTED_ENVIRONMENT + + val result = customHeadersManager.getCustomHeaders( + url = "https://api.pwnedpasswords.com/range/12345", + ) + + assertEquals(emptyMap(), result) + verify(exactly = 0) { customHeadersDiskSource.getCustomHeaders(id = any()) } + } + + @Test + fun `getCustomHeaders by url should return empty map when the url is malformed`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = SELF_HOSTED_ENVIRONMENT + + val result = customHeadersManager.getCustomHeaders(url = "not a valid url") + + assertEquals(emptyMap(), result) + verify(exactly = 0) { customHeadersDiskSource.getCustomHeaders(id = any()) } + } + + @Suppress("MaxLineLength") + @Test + fun `getCustomHeaders by url should return empty map when the url scheme does not match the environment`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = SELF_HOSTED_ENVIRONMENT + + val result = customHeadersManager.getCustomHeaders( + url = SELF_HOSTED_ICON_URL.replace("https://", "http://"), + ) + + assertEquals(emptyMap(), result) + verify(exactly = 0) { customHeadersDiskSource.getCustomHeaders(id = any()) } + } + + @Suppress("MaxLineLength") + @Test + fun `getCustomHeaders by url should return the stored headers for an environment host url`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = SELF_HOSTED_ENVIRONMENT + every { + customHeadersDiskSource.getCustomHeaders(id = CUSTOM_HEADERS_ID) + } returns CUSTOM_HEADERS + + val result = customHeadersManager.getCustomHeaders(url = SELF_HOSTED_ICON_URL) + + assertEquals(CUSTOM_HEADERS, result) + } + + @Suppress("MaxLineLength") + @Test + fun `getCustomHeaders by url should return empty map when no headers are stored for the id`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = SELF_HOSTED_ENVIRONMENT + every { customHeadersDiskSource.getCustomHeaders(id = CUSTOM_HEADERS_ID) } returns null + + val result = customHeadersManager.getCustomHeaders(url = SELF_HOSTED_ICON_URL) + + assertEquals(emptyMap(), result) + } + + @Test + fun `getStoredCustomHeaders should return the stored headers for the id`() { + every { + customHeadersDiskSource.getCustomHeaders(id = CUSTOM_HEADERS_ID) + } returns CUSTOM_HEADERS + + assertEquals( + CUSTOM_HEADERS, + customHeadersManager.getStoredCustomHeaders(id = CUSTOM_HEADERS_ID), + ) + } + + @Test + fun `getStoredCustomHeaders should return null when no headers are stored for the id`() { + every { customHeadersDiskSource.getCustomHeaders(id = "unknownId") } returns null + + assertNull(customHeadersManager.getStoredCustomHeaders(id = "unknownId")) + } + + @Test + fun `saveCustomHeaders should store the headers under a fresh id and return it`() { + val idSlot = slot() + every { + customHeadersDiskSource.storeCustomHeaders( + id = capture(idSlot), + headers = CUSTOM_HEADERS, + ) + } just runs + + val result = customHeadersManager.saveCustomHeaders(headers = CUSTOM_HEADERS) + + assertEquals(idSlot.captured, result) + verify(exactly = 1) { + customHeadersDiskSource.storeCustomHeaders(id = result, headers = CUSTOM_HEADERS) + } + } + + @Test + fun `removeCustomHeaders should delete the headers when nothing references the id`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = SELF_HOSTED_ENVIRONMENT.copy( + customHeadersId = null, + ) + fakeAuthDiskSource.userState = null + + customHeadersManager.removeCustomHeaders(id = CUSTOM_HEADERS_ID) + + verify(exactly = 1) { + customHeadersDiskSource.storeCustomHeaders(id = CUSTOM_HEADERS_ID, headers = null) + } + } + + @Suppress("MaxLineLength") + @Test + fun `removeCustomHeaders should not delete the headers when the pre-auth environment references the id`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = SELF_HOSTED_ENVIRONMENT + fakeAuthDiskSource.userState = null + + customHeadersManager.removeCustomHeaders(id = CUSTOM_HEADERS_ID) + + verify(exactly = 0) { + customHeadersDiskSource.storeCustomHeaders(id = any(), headers = null) + } + } + + @Suppress("MaxLineLength") + @Test + fun `removeCustomHeaders should not delete the headers when an account environment references the id`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = SELF_HOSTED_ENVIRONMENT.copy( + customHeadersId = null, + ) + fakeAuthDiskSource.userState = UserStateJson( + activeUserId = USER_ID_1, + accounts = mapOf(USER_ID_1 to ACCOUNT_1_WITH_CUSTOM_HEADERS), + ) + + customHeadersManager.removeCustomHeaders(id = CUSTOM_HEADERS_ID) + + verify(exactly = 0) { + customHeadersDiskSource.storeCustomHeaders(id = any(), headers = null) + } + } + + @Suppress("MaxLineLength") + @Test + fun `removeCustomHeadersForUser should delete the headers when no other environment references the id`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = SELF_HOSTED_ENVIRONMENT.copy( + customHeadersId = null, + ) + fakeAuthDiskSource.userState = UserStateJson( + activeUserId = USER_ID_1, + accounts = mapOf( + USER_ID_1 to ACCOUNT_1_WITH_CUSTOM_HEADERS, + USER_ID_2 to createMockAccountJson(number = 2), + ), + ) + + customHeadersManager.removeCustomHeadersForUser(userId = USER_ID_1) + + verify(exactly = 1) { + customHeadersDiskSource.storeCustomHeaders(id = CUSTOM_HEADERS_ID, headers = null) + } + } + + @Suppress("MaxLineLength") + @Test + fun `removeCustomHeadersForUser should keep the headers when the pre-auth environment references the id`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = SELF_HOSTED_ENVIRONMENT + fakeAuthDiskSource.userState = UserStateJson( + activeUserId = USER_ID_1, + accounts = mapOf(USER_ID_1 to ACCOUNT_1_WITH_CUSTOM_HEADERS), + ) + + customHeadersManager.removeCustomHeadersForUser(userId = USER_ID_1) + + verify(exactly = 0) { + customHeadersDiskSource.storeCustomHeaders(id = any(), headers = null) + } + } + + @Suppress("MaxLineLength") + @Test + fun `removeCustomHeadersForUser should keep the headers when another account references the id`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = SELF_HOSTED_ENVIRONMENT.copy( + customHeadersId = null, + ) + fakeAuthDiskSource.userState = UserStateJson( + activeUserId = USER_ID_1, + accounts = mapOf( + USER_ID_1 to ACCOUNT_1_WITH_CUSTOM_HEADERS, + USER_ID_2 to createMockAccountJson( + number = 2, + settings = AccountJson.Settings( + environmentUrlData = SELF_HOSTED_ENVIRONMENT, + ), + ), + ), + ) + + customHeadersManager.removeCustomHeadersForUser(userId = USER_ID_1) + + verify(exactly = 0) { + customHeadersDiskSource.storeCustomHeaders(id = any(), headers = null) + } + } + + @Suppress("MaxLineLength") + @Test + fun `removeCustomHeadersForUser should do nothing when the account has no custom headers id`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = SELF_HOSTED_ENVIRONMENT + fakeAuthDiskSource.userState = UserStateJson( + activeUserId = USER_ID_1, + accounts = mapOf(USER_ID_1 to createMockAccountJson(number = 1)), + ) + + customHeadersManager.removeCustomHeadersForUser(userId = USER_ID_1) + + verify(exactly = 0) { + customHeadersDiskSource.storeCustomHeaders(id = any(), headers = any()) + } + } +} + +private const val CUSTOM_HEADERS_ID = "mockCustomHeadersId" +private const val USER_ID_1 = "mockId-1" +private const val USER_ID_2 = "mockId-2" +private const val SELF_HOSTED_ICON_URL = + "https://vault.example.com/icons/bitwarden.com/icon.png" +private val CUSTOM_HEADERS = mapOf( + "CF-Access-Client-Id" to "clientId", + "CF-Access-Client-Secret" to "clientSecret", +) +private val SELF_HOSTED_ENVIRONMENT = EnvironmentUrlDataJson( + base = "https://vault.example.com", + customHeadersId = CUSTOM_HEADERS_ID, +) +private val ACCOUNT_1_WITH_CUSTOM_HEADERS = createMockAccountJson( + number = 1, + settings = AccountJson.Settings( + environmentUrlData = SELF_HOSTED_ENVIRONMENT, + ), +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryTests.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryTests.kt index e9e8bf60e99..f9c2e9a0b1e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryTests.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryTests.kt @@ -41,6 +41,7 @@ class SdkRepositoryFactoryTests { authTokenProvider = mockk(), certificateProvider = mockk(), cookieProvider = mockk(), + customHeadersProvider = mockk(), permissionProvider = mockk(), clock = FIXED_CLOCK, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/EnvironmentRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/EnvironmentRepositoryTest.kt index cb9b1715c42..afdd7186c56 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/EnvironmentRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/EnvironmentRepositoryTest.kt @@ -10,10 +10,14 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.FakeEnvironmentDiskSource +import com.x8bit.bitwarden.data.platform.manager.CustomHeadersManager import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.runs import io.mockk.unmockkStatic +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -28,10 +32,14 @@ class EnvironmentRepositoryTest { private val fakeEnvironmentDiskSource = FakeEnvironmentDiskSource() private val fakeAuthDiskSource = FakeAuthDiskSource() + private val customHeadersManager: CustomHeadersManager = mockk { + every { removeCustomHeaders(id = any()) } just runs + } private val repository: EnvironmentRepository = EnvironmentRepositoryImpl( environmentDiskSource = fakeEnvironmentDiskSource, authDiskSource = fakeAuthDiskSource, + customHeadersManager = customHeadersManager, dispatcherManager = dispatcherManager, ) @@ -148,6 +156,48 @@ class EnvironmentRepositoryTest { } } + @Test + fun `setting environment should remove custom headers no longer referenced by the new value`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = EnvironmentUrlDataJson( + base = "https://vault.example.com", + customHeadersId = "headersId", + ) + + repository.environment = Environment.Prod.Us + + assertEquals( + EnvironmentUrlDataJson.DEFAULT_US, + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData, + ) + verify(exactly = 1) { customHeadersManager.removeCustomHeaders(id = "headersId") } + } + + @Test + fun `setting environment should not remove custom headers still referenced by the new value`() { + val environmentUrlData = EnvironmentUrlDataJson( + base = "https://vault.example.com", + customHeadersId = "headersId", + ) + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = environmentUrlData + + repository.environment = Environment.SelfHosted( + environmentUrlData = environmentUrlData.copy(webVault = "https://web.example.com"), + ) + + verify(exactly = 0) { customHeadersManager.removeCustomHeaders(id = any()) } + } + + @Test + fun `setting environment should not remove custom headers when none were referenced`() { + fakeEnvironmentDiskSource.preAuthEnvironmentUrlData = EnvironmentUrlDataJson( + base = "https://vault.example.com", + ) + + repository.environment = Environment.Prod.Us + + verify(exactly = 0) { customHeadersManager.removeCustomHeaders(id = any()) } + } + @Test fun `loadEnvironmentForEmail should update the environment`() = runTest { val environmentUrlDataJson = mockk() diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt index 91b47a7f372..92137c43349 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo @@ -323,6 +324,87 @@ class EnvironmentScreenTest : BitwardenComposeTest() { } } + @Test + fun `add header click should send AddHeaderClick`() { + composeTestRule + .onNodeWithText("Add header") + .performScrollTo() + .performClick() + verify { + viewModel.trySendAction(EnvironmentAction.AddHeaderClick) + } + } + + @Test + fun `header name change should send HeaderNameChange`() { + mutableStateFlow.update { + it.copy( + customHeaders = listOf(EnvironmentState.CustomHeaderField(id = "id-1")), + ) + } + composeTestRule + .onNodeWithText("Name") + .performScrollTo() + .performTextInput("CF-Access-Client-Id") + verify { + viewModel.trySendAction( + EnvironmentAction.HeaderNameChange(id = "id-1", name = "CF-Access-Client-Id"), + ) + } + } + + @Test + fun `header value change should send HeaderValueChange`() { + mutableStateFlow.update { + it.copy( + customHeaders = listOf(EnvironmentState.CustomHeaderField(id = "id-1")), + ) + } + composeTestRule + .onNodeWithText("Value") + .performScrollTo() + .performTextInput("mock-value") + verify { + viewModel.trySendAction( + EnvironmentAction.HeaderValueChange(id = "id-1", value = "mock-value"), + ) + } + } + + @Test + fun `header value visibility toggle click should send HeaderValueVisibilityChange`() { + mutableStateFlow.update { + it.copy( + customHeaders = listOf(EnvironmentState.CustomHeaderField(id = "id-1")), + ) + } + composeTestRule + .onNodeWithTag("HeaderValueVisibilityToggle") + .performScrollTo() + .performClick() + verify { + viewModel.trySendAction( + EnvironmentAction.HeaderValueVisibilityChange(id = "id-1", isVisible = true), + ) + } + } + + @Test + fun `remove header click should send RemoveHeaderClick`() { + mutableStateFlow.update { + it.copy( + customHeaders = listOf(EnvironmentState.CustomHeaderField(id = "id-1")), + ) + } + composeTestRule + .onNodeWithText("Remove header") + .performScrollTo() + .performClick() + verify { + viewModel.trySendAction(EnvironmentAction.RemoveHeaderClick(id = "id-1")) + } + } + @Test fun `ConfirmOverwriteCertificate dialog should display based on state`() { composeTestRule.onNode(isDialog()).assertDoesNotExist() @@ -436,6 +518,8 @@ private val DEFAULT_STATE: EnvironmentState = EnvironmentState( apiServerUrl = "", identityServerUrl = "", iconsServerUrl = "", + customHeaders = emptyList(), + customHeadersId = null, keyHost = null, dialog = null, isRelease = true, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt index 9942cc64069..7d565a1e34a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt @@ -16,6 +16,7 @@ import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.data.platform.datasource.disk.model.MutualTlsKeyHost import com.x8bit.bitwarden.data.platform.manager.CertificateManager +import com.x8bit.bitwarden.data.platform.manager.CustomHeadersManager import com.x8bit.bitwarden.data.platform.manager.model.ImportPrivateKeyResult import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import com.x8bit.bitwarden.ui.platform.manager.keychain.model.PrivateKeyAliasSelectionResult @@ -38,6 +39,11 @@ class EnvironmentViewModelTest : BaseViewModelTest() { private val mockCertificateManager = mockk { every { getMutualTlsKeyAliases() } returns emptyList() } + private val mockCustomHeadersManager = mockk { + every { getStoredCustomHeaders(id = any()) } returns null + every { saveCustomHeaders(headers = any()) } returns "mockHeadersId" + every { removeCustomHeaders(id = any()) } just runs + } private val mockFileManager = mockk() private val snackbarRelayManager = mockk> { every { sendSnackbarData(data = any(), relay = any()) } just runs @@ -82,6 +88,41 @@ class EnvironmentViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `initial state should load the stored custom headers sorted by name when the current environment has a custom headers ID`() { + every { + mockCustomHeadersManager.getStoredCustomHeaders(id = "mockHeadersId") + } returns mapOf("Header-B" to "2", "Header-A" to "1") + fakeEnvironmentRepository.environment = Environment.SelfHosted( + environmentUrlData = EnvironmentUrlDataJson( + base = "self-hosted-base", + customHeadersId = "mockHeadersId", + ), + ) + val viewModel = createViewModel() + val customHeaders = viewModel.stateFlow.value.customHeaders + assertEquals( + DEFAULT_STATE.copy( + serverUrl = "self-hosted-base", + customHeaders = listOf( + EnvironmentState.CustomHeaderField( + id = customHeaders[0].id, + name = "Header-A", + value = "1", + ), + EnvironmentState.CustomHeaderField( + id = customHeaders[1].id, + name = "Header-B", + value = "2", + ), + ), + customHeadersId = "mockHeadersId", + ), + viewModel.stateFlow.value, + ) + } + @Test fun `initial state should be correct when restoring from the save state handle`() { val savedState = DEFAULT_STATE.copy( @@ -114,6 +155,32 @@ class EnvironmentViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `initial state should restore the stored custom header values when restoring from the save state handle`() { + every { + mockCustomHeadersManager.getStoredCustomHeaders(id = "mockHeadersId") + } returns mapOf("Header-A" to "1") + // Header values are excluded from the saved state, so a restored field's value is empty. + val savedState = DEFAULT_STATE.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField(id = "id-1", name = "Header-A", value = ""), + ), + customHeadersId = "mockHeadersId", + ) + val viewModel = createViewModel( + savedStateHandle = SavedStateHandle(initialState = mapOf("state" to savedState)), + ) + assertEquals( + savedState.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField(id = "id-1", name = "Header-A", value = "1"), + ), + ), + viewModel.stateFlow.value, + ) + } + @Test fun `CloseClick should emit NavigateBack`() = runTest { val viewModel = createViewModel() @@ -278,6 +345,258 @@ class EnvironmentViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `SaveClick should save the trimmed custom headers and update the environment with the returned custom headers ID`() = + runTest { + val initialState = DEFAULT_STATE.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField( + id = "id-1", + name = " Header-A ", + value = " 1 ", + ), + EnvironmentState.CustomHeaderField(id = "id-2", name = "", value = ""), + ), + ) + val viewModel = createViewModel( + savedStateHandle = SavedStateHandle( + initialState = mapOf( + "state" to initialState, + ), + ), + ) + + viewModel.trySendAction(EnvironmentAction.SaveClick) + + assertEquals( + Environment.SelfHosted( + environmentUrlData = EnvironmentUrlDataJson( + base = "", + customHeadersId = "mockHeadersId", + ), + ), + fakeEnvironmentRepository.environment, + ) + assertEquals( + initialState.copy(customHeadersId = "mockHeadersId"), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { + mockCustomHeadersManager.saveCustomHeaders(headers = mapOf("Header-A" to "1")) + } + verify(exactly = 0) { + mockCustomHeadersManager.removeCustomHeaders(id = any()) + } + } + + @Test + fun `SaveClick with unchanged custom headers should reuse the existing custom headers ID`() = + runTest { + every { + mockCustomHeadersManager.getStoredCustomHeaders(id = "previousHeadersId") + } returns mapOf("Header-A" to "1") + val initialState = DEFAULT_STATE.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField(id = "id-1", name = "Header-A", value = "1"), + ), + customHeadersId = "previousHeadersId", + ) + val viewModel = createViewModel( + savedStateHandle = SavedStateHandle( + initialState = mapOf( + "state" to initialState, + ), + ), + ) + + viewModel.trySendAction(EnvironmentAction.SaveClick) + + assertEquals( + Environment.SelfHosted( + environmentUrlData = EnvironmentUrlDataJson( + base = "", + customHeadersId = "previousHeadersId", + ), + ), + fakeEnvironmentRepository.environment, + ) + assertEquals( + initialState.copy(customHeadersId = "previousHeadersId"), + viewModel.stateFlow.value, + ) + verify(exactly = 0) { + mockCustomHeadersManager.saveCustomHeaders(headers = any()) + mockCustomHeadersManager.removeCustomHeaders(id = any()) + } + } + + @Test + fun `SaveClick with changed custom headers should save them under a new ID`() = + runTest { + every { + mockCustomHeadersManager.getStoredCustomHeaders(id = "previousHeadersId") + } returns mapOf("Header-A" to "1") + val initialState = DEFAULT_STATE.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField( + id = "id-1", + name = "Header-A", + value = "updated", + ), + ), + customHeadersId = "previousHeadersId", + ) + val viewModel = createViewModel( + savedStateHandle = SavedStateHandle( + initialState = mapOf( + "state" to initialState, + ), + ), + ) + + viewModel.trySendAction(EnvironmentAction.SaveClick) + + assertEquals( + Environment.SelfHosted( + environmentUrlData = EnvironmentUrlDataJson( + base = "", + customHeadersId = "mockHeadersId", + ), + ), + fakeEnvironmentRepository.environment, + ) + assertEquals( + initialState.copy(customHeadersId = "mockHeadersId"), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { + mockCustomHeadersManager.saveCustomHeaders( + headers = mapOf("Header-A" to "updated"), + ) + } + } + + @Test + fun `SaveClick with all custom header fields removed should clear the custom headers ID`() = + runTest { + val initialState = DEFAULT_STATE.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField(id = "id-1", name = "Header-A", value = "1"), + ), + customHeadersId = "previousHeadersId", + ) + val viewModel = createViewModel( + savedStateHandle = SavedStateHandle( + initialState = mapOf( + "state" to initialState, + ), + ), + ) + + viewModel.trySendAction(EnvironmentAction.RemoveHeaderClick(id = "id-1")) + viewModel.trySendAction(EnvironmentAction.SaveClick) + + assertEquals( + Environment.SelfHosted( + environmentUrlData = EnvironmentUrlDataJson( + base = "", + customHeadersId = null, + ), + ), + fakeEnvironmentRepository.environment, + ) + assertEquals( + initialState.copy( + customHeaders = emptyList(), + customHeadersId = null, + ), + viewModel.stateFlow.value, + ) + verify(exactly = 0) { + mockCustomHeadersManager.saveCustomHeaders(headers = any()) + } + } + + @Test + fun `SaveClick should show the error dialog when a custom header name is invalid`() = runTest { + val initialState = DEFAULT_STATE.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField( + id = "id-1", + name = "Invalid Header", + value = "1", + ), + ), + ) + val viewModel = createViewModel( + savedStateHandle = SavedStateHandle(initialState = mapOf("state" to initialState)), + ) + + viewModel.trySendAction(EnvironmentAction.SaveClick) + + assertEquals( + initialState.copy( + dialog = EnvironmentState.DialogState.Error( + message = BitwardenString.one_or_more_custom_headers_are_invalid.asText(), + ), + ), + viewModel.stateFlow.value, + ) + assertEquals(Environment.Prod.Us, fakeEnvironmentRepository.environment) + verify(exactly = 0) { mockCustomHeadersManager.saveCustomHeaders(headers = any()) } + } + + @Test + fun `SaveClick should show the error dialog when a custom header value is missing`() = runTest { + val initialState = DEFAULT_STATE.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField(id = "id-1", name = "Header-A", value = ""), + ), + ) + val viewModel = createViewModel( + savedStateHandle = SavedStateHandle(initialState = mapOf("state" to initialState)), + ) + + viewModel.trySendAction(EnvironmentAction.SaveClick) + + assertEquals( + initialState.copy( + dialog = EnvironmentState.DialogState.Error( + message = BitwardenString.one_or_more_custom_headers_are_invalid.asText(), + ), + ), + viewModel.stateFlow.value, + ) + assertEquals(Environment.Prod.Us, fakeEnvironmentRepository.environment) + } + + @Test + fun `SaveClick should show the error dialog when custom header names are duplicated`() = + runTest { + val initialState = DEFAULT_STATE.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField(id = "id-1", name = "Header-A", value = "1"), + EnvironmentState.CustomHeaderField(id = "id-2", name = "Header-A", value = "2"), + ), + ) + val viewModel = createViewModel( + savedStateHandle = SavedStateHandle(initialState = mapOf("state" to initialState)), + ) + + viewModel.trySendAction(EnvironmentAction.SaveClick) + + assertEquals( + initialState.copy( + dialog = EnvironmentState.DialogState.Error( + message = BitwardenString.one_or_more_custom_headers_are_invalid.asText(), + ), + ), + viewModel.stateFlow.value, + ) + assertEquals(Environment.Prod.Us, fakeEnvironmentRepository.environment) + } + @Test fun `ServerUrlChange should update the server URL`() { val viewModel = createViewModel() @@ -338,6 +657,148 @@ class EnvironmentViewModelTest : BaseViewModelTest() { ) } + @Test + fun `AddHeaderClick should append an empty custom header field`() { + val viewModel = createViewModel() + viewModel.trySendAction(EnvironmentAction.AddHeaderClick) + val customHeaders = viewModel.stateFlow.value.customHeaders + assertEquals( + DEFAULT_STATE.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField(id = customHeaders[0].id), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `HeaderNameChange should update the name of only the matching header field`() { + val initialState = DEFAULT_STATE.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField(id = "id-1", name = "Header-A", value = "1"), + EnvironmentState.CustomHeaderField(id = "id-2", name = "Header-B", value = "2"), + ), + ) + val viewModel = createViewModel( + savedStateHandle = SavedStateHandle( + initialState = mapOf( + "state" to initialState, + ), + ), + ) + viewModel.trySendAction( + EnvironmentAction.HeaderNameChange(id = "id-1", name = "Updated-Header"), + ) + assertEquals( + initialState.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField( + id = "id-1", + name = "Updated-Header", + value = "1", + ), + EnvironmentState.CustomHeaderField(id = "id-2", name = "Header-B", value = "2"), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `HeaderValueChange should update the value of only the matching header field`() { + val initialState = DEFAULT_STATE.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField(id = "id-1", name = "Header-A", value = "1"), + EnvironmentState.CustomHeaderField(id = "id-2", name = "Header-B", value = "2"), + ), + ) + val viewModel = createViewModel( + savedStateHandle = SavedStateHandle( + initialState = mapOf( + "state" to initialState, + ), + ), + ) + viewModel.trySendAction( + EnvironmentAction.HeaderValueChange(id = "id-2", value = "updated-value"), + ) + assertEquals( + initialState.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField(id = "id-1", name = "Header-A", value = "1"), + EnvironmentState.CustomHeaderField( + id = "id-2", + name = "Header-B", + value = "updated-value", + ), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `HeaderValueVisibilityChange should update the value visibility of only the matching header field`() { + val initialState = DEFAULT_STATE.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField(id = "id-1", name = "Header-A", value = "1"), + EnvironmentState.CustomHeaderField(id = "id-2", name = "Header-B", value = "2"), + ), + ) + val viewModel = createViewModel( + savedStateHandle = SavedStateHandle( + initialState = mapOf( + "state" to initialState, + ), + ), + ) + viewModel.trySendAction( + EnvironmentAction.HeaderValueVisibilityChange(id = "id-1", isVisible = true), + ) + assertEquals( + initialState.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField( + id = "id-1", + name = "Header-A", + value = "1", + isValueVisible = true, + ), + EnvironmentState.CustomHeaderField(id = "id-2", name = "Header-B", value = "2"), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `RemoveHeaderClick should remove the matching header field`() { + val initialState = DEFAULT_STATE.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField(id = "id-1", name = "Header-A", value = "1"), + EnvironmentState.CustomHeaderField(id = "id-2", name = "Header-B", value = "2"), + ), + ) + val viewModel = createViewModel( + savedStateHandle = SavedStateHandle( + initialState = mapOf( + "state" to initialState, + ), + ), + ) + viewModel.trySendAction(EnvironmentAction.RemoveHeaderClick(id = "id-1")) + assertEquals( + initialState.copy( + customHeaders = listOf( + EnvironmentState.CustomHeaderField(id = "id-2", name = "Header-B", value = "2"), + ), + ), + viewModel.stateFlow.value, + ) + } + @Suppress("MaxLineLength") @Test fun `SystemCertificateSelectionResultReceive should update key alias and key host when successful`() { @@ -819,6 +1280,7 @@ class EnvironmentViewModelTest : BaseViewModelTest() { EnvironmentViewModel( environmentRepository = fakeEnvironmentRepository, certificateManager = mockCertificateManager, + customHeadersManager = mockCustomHeadersManager, fileManager = mockFileManager, snackbarRelayManager = snackbarRelayManager, buildInfoManager = buildInfoManager, @@ -835,6 +1297,8 @@ private val DEFAULT_STATE: EnvironmentState = EnvironmentState( apiServerUrl = "", identityServerUrl = "", iconsServerUrl = "", + customHeaders = emptyList(), + customHeadersId = null, keyHost = null, dialog = null, isRelease = true, diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt index 2cf05880486..d4b937a4cc4 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt @@ -13,6 +13,7 @@ import com.bitwarden.network.model.AuthTokenData import com.bitwarden.network.model.BitwardenServiceClientConfig import com.bitwarden.network.model.NetworkCookie import com.bitwarden.network.provider.CookieProvider +import com.bitwarden.network.provider.CustomHeadersProvider import com.bitwarden.network.provider.PermissionProvider import com.bitwarden.network.service.ConfigService import com.bitwarden.network.service.DownloadService @@ -84,6 +85,9 @@ object PlatformNetworkModule { override fun acquireCookies(hostname: String): Unit = Unit }, + customHeadersProvider = object : CustomHeadersProvider { + override fun getCustomHeaders(url: String): Map = emptyMap() + }, permissionProvider = object : PermissionProvider { override val errorMessageString: String get() = "Error" diff --git a/data/src/main/kotlin/com/bitwarden/data/datasource/disk/model/EnvironmentUrlDataJson.kt b/data/src/main/kotlin/com/bitwarden/data/datasource/disk/model/EnvironmentUrlDataJson.kt index 38d5385dbee..87a73b0057f 100644 --- a/data/src/main/kotlin/com/bitwarden/data/datasource/disk/model/EnvironmentUrlDataJson.kt +++ b/data/src/main/kotlin/com/bitwarden/data/datasource/disk/model/EnvironmentUrlDataJson.kt @@ -8,6 +8,8 @@ import kotlinx.serialization.Serializable * * @property base The overall base URL. * @property keyUri A Uri containing the alias and host of the key used for mutual TLS. + * @property customHeadersId The identifier of the custom headers stored in encrypted storage + * (if applicable). * @property api Separate base URL for the "/api" domain (if applicable). * @property identity Separate base URL for the "/identity" domain (if applicable). * @property icon Separate base URL for the icon domain (if applicable). @@ -23,6 +25,9 @@ data class EnvironmentUrlDataJson( @SerialName("keyUri") val keyUri: String? = null, + @SerialName("customHeadersId") + val customHeadersId: String? = null, + @SerialName("api") val api: String? = null, diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt index f7671c6be9a..732e77f1787 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt @@ -4,6 +4,7 @@ import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.network.interceptor.AuthTokenManager import com.bitwarden.network.interceptor.BaseUrlInterceptors import com.bitwarden.network.interceptor.CookieInterceptor +import com.bitwarden.network.interceptor.CustomHeadersInterceptor import com.bitwarden.network.interceptor.HeadersInterceptor import com.bitwarden.network.interceptor.PermissionInterceptor import com.bitwarden.network.model.BitwardenServiceClientConfig @@ -74,6 +75,9 @@ internal class BitwardenServiceClientImpl( cookieInterceptor = CookieInterceptor( cookieProvider = cookieProvider, ), + customHeadersInterceptor = CustomHeadersInterceptor( + customHeadersProvider = bitwardenServiceClientConfig.customHeadersProvider, + ), permissionInterceptor = PermissionInterceptor( permissionProvider = bitwardenServiceClientConfig.permissionProvider, ), diff --git a/network/src/main/kotlin/com/bitwarden/network/interceptor/CustomHeadersInterceptor.kt b/network/src/main/kotlin/com/bitwarden/network/interceptor/CustomHeadersInterceptor.kt new file mode 100644 index 00000000000..d44b0a85db0 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/interceptor/CustomHeadersInterceptor.kt @@ -0,0 +1,30 @@ +package com.bitwarden.network.interceptor + +import com.bitwarden.network.provider.CustomHeadersProvider +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Interceptor responsible for attaching the user's custom headers to requests sent to a + * self-hosted environment. + * + * The [CustomHeadersProvider] scopes the headers to the environment's hosts, so requests to + * third-party hosts (e.g. Have I Been Pwned) are left untouched. This must be installed as a + * network interceptor so the header values, which may contain credentials such as Cloudflare + * Access service tokens, are never seen by the application-level logging interceptor. + */ +class CustomHeadersInterceptor( + private val customHeadersProvider: CustomHeadersProvider, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val customHeaders = customHeadersProvider.getCustomHeaders(url = request.url.toString()) + if (customHeaders.isEmpty()) return chain.proceed(request) + return chain.proceed( + request + .newBuilder() + .apply { customHeaders.forEach { (name, value) -> header(name, value) } } + .build(), + ) + } +} diff --git a/network/src/main/kotlin/com/bitwarden/network/model/BitwardenServiceClientConfig.kt b/network/src/main/kotlin/com/bitwarden/network/model/BitwardenServiceClientConfig.kt index 4889dbe13c8..f95eff5ee28 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/BitwardenServiceClientConfig.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/BitwardenServiceClientConfig.kt @@ -5,6 +5,7 @@ import com.bitwarden.network.interceptor.AuthTokenProvider import com.bitwarden.network.interceptor.BaseUrlsProvider import com.bitwarden.network.provider.AppIdProvider import com.bitwarden.network.provider.CookieProvider +import com.bitwarden.network.provider.CustomHeadersProvider import com.bitwarden.network.provider.PermissionProvider import com.bitwarden.network.ssl.CertificateProvider import java.time.Clock @@ -19,6 +20,7 @@ data class BitwardenServiceClientConfig( val authTokenProvider: AuthTokenProvider, val certificateProvider: CertificateProvider, val cookieProvider: CookieProvider, + val customHeadersProvider: CustomHeadersProvider, val permissionProvider: PermissionProvider, val clock: Clock, val enableHttpBodyLogging: Boolean = false, diff --git a/network/src/main/kotlin/com/bitwarden/network/provider/CustomHeadersProvider.kt b/network/src/main/kotlin/com/bitwarden/network/provider/CustomHeadersProvider.kt new file mode 100644 index 00000000000..921a4433f91 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/provider/CustomHeadersProvider.kt @@ -0,0 +1,21 @@ +package com.bitwarden.network.provider + +/** + * Provider for user-configured custom headers sent with requests to a self-hosted environment. + * + * This supports self-hosted servers sitting behind a reverse proxy that gates access on a + * header, such as Cloudflare Access service tokens. + */ +interface CustomHeadersProvider { + /** + * Retrieves the custom headers to attach to a request to [url]. + * + * Returns an empty map when no custom headers are configured or when [url] does not belong + * to the current environment's hosts, so header values, which may contain credentials, are + * never sent to third parties. + * + * @param url The URL of the request the headers will be attached to. + * @return The custom headers to attach, or an empty map if there are none. + */ + fun getCustomHeaders(url: String): Map +} diff --git a/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt b/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt index 626d7cf75dd..88f0563c062 100644 --- a/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt @@ -5,11 +5,14 @@ import com.bitwarden.network.interceptor.AuthTokenManager import com.bitwarden.network.interceptor.BaseUrlInterceptor import com.bitwarden.network.interceptor.BaseUrlInterceptors import com.bitwarden.network.interceptor.CookieInterceptor +import com.bitwarden.network.interceptor.CustomHeadersInterceptor import com.bitwarden.network.interceptor.HeadersInterceptor import com.bitwarden.network.interceptor.PermissionInterceptor import com.bitwarden.network.ssl.CertificateProvider import com.bitwarden.network.ssl.configureSsl import com.bitwarden.network.util.HEADER_KEY_AUTHORIZATION +import com.bitwarden.network.util.HEADER_KEY_COOKIE +import com.bitwarden.network.util.HEADER_KEY_SET_COOKIE import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -26,6 +29,7 @@ internal class RetrofitsImpl( authTokenManager: AuthTokenManager, baseUrlInterceptors: BaseUrlInterceptors, cookieInterceptor: CookieInterceptor, + customHeadersInterceptor: CustomHeadersInterceptor, headersInterceptor: HeadersInterceptor, json: Json, private val permissionInterceptor: PermissionInterceptor, @@ -97,6 +101,8 @@ internal class RetrofitsImpl( HttpLoggingInterceptor { message -> Timber.tag("BitwardenNetworkClient").d(message) } .apply { redactHeader(name = HEADER_KEY_AUTHORIZATION) + redactHeader(name = HEADER_KEY_COOKIE) + redactHeader(name = HEADER_KEY_SET_COOKIE) setLevel( level = HttpLoggingInterceptor.Level.BODY .takeIf { logHttpBody } @@ -108,14 +114,18 @@ internal class RetrofitsImpl( private val baseOkHttpClient: OkHttpClient = OkHttpClient.Builder() .addInterceptor(headersInterceptor) .addNetworkInterceptor(cookieInterceptor) + .addNetworkInterceptor(customHeadersInterceptor) .configureSsl(certificateProvider = certificateProvider) .build() // For requests to external (non-Bitwarden) URLs. CookieInterceptor must be excluded because // it treats all 302s as Bitwarden load-balancer auth redirects, which is only correct for - // Bitwarden's own infrastructure. + // Bitwarden's own infrastructure. CustomHeadersInterceptor is included because the + // fill-assist URL may point at the user's self-hosted environment; its provider scopes the + // headers to the environment's hosts. private val externalOkHttpClient: OkHttpClient = OkHttpClient.Builder() .addInterceptor(headersInterceptor) + .addNetworkInterceptor(customHeadersInterceptor) .configureSsl(certificateProvider = certificateProvider) .build() diff --git a/network/src/main/kotlin/com/bitwarden/network/util/HeaderUtils.kt b/network/src/main/kotlin/com/bitwarden/network/util/HeaderUtils.kt index d6b2d8fb130..33b9715a331 100644 --- a/network/src/main/kotlin/com/bitwarden/network/util/HeaderUtils.kt +++ b/network/src/main/kotlin/com/bitwarden/network/util/HeaderUtils.kt @@ -25,6 +25,16 @@ internal const val HEADER_KEY_USER_AGENT: String = "User-Agent" */ internal const val HEADER_KEY_DEVICE_TYPE: String = "Device-Type" +/** + * The key used for the 'cookie' headers. + */ +internal const val HEADER_KEY_COOKIE: String = "Cookie" + +/** + * The key used for the 'set-cookie' headers. + */ +internal const val HEADER_KEY_SET_COOKIE: String = "Set-Cookie" + /** * The bearer prefix used for the 'authorization' headers value. */ diff --git a/network/src/test/kotlin/com/bitwarden/network/interceptor/CustomHeadersInterceptorTest.kt b/network/src/test/kotlin/com/bitwarden/network/interceptor/CustomHeadersInterceptorTest.kt new file mode 100644 index 00000000000..a7a8203c959 --- /dev/null +++ b/network/src/test/kotlin/com/bitwarden/network/interceptor/CustomHeadersInterceptorTest.kt @@ -0,0 +1,68 @@ +package com.bitwarden.network.interceptor + +import com.bitwarden.network.provider.CustomHeadersProvider +import io.mockk.every +import io.mockk.mockk +import okhttp3.Request +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class CustomHeadersInterceptorTest { + + private val mockCustomHeadersProvider: CustomHeadersProvider = mockk() + + private val interceptor = CustomHeadersInterceptor( + customHeadersProvider = mockCustomHeadersProvider, + ) + + @Test + fun `intercept should add the provided custom headers to the request`() { + every { + mockCustomHeadersProvider.getCustomHeaders(url = "https://vault.example.com/api/sync") + } returns mapOf( + "CF-Access-Client-Id" to "client-id", + "CF-Access-Client-Secret" to "client-secret", + ) + val originalRequest = Request.Builder() + .url("https://vault.example.com/api/sync") + .build() + val chain = FakeInterceptorChain(originalRequest) + + val response = interceptor.intercept(chain) + + assertEquals("client-id", response.request.header("CF-Access-Client-Id")) + assertEquals("client-secret", response.request.header("CF-Access-Client-Secret")) + } + + @Test + fun `intercept should preserve existing request headers when adding custom headers`() { + every { + mockCustomHeadersProvider.getCustomHeaders(url = any()) + } returns mapOf("Custom-Header" to "value") + val originalRequest = Request.Builder() + .url("https://vault.example.com/api/sync") + .header("Bitwarden-Client-Name", "mobile") + .build() + val chain = FakeInterceptorChain(originalRequest) + + val response = interceptor.intercept(chain) + + assertEquals("mobile", response.request.header("Bitwarden-Client-Name")) + assertEquals("value", response.request.header("Custom-Header")) + } + + @Test + fun `intercept should leave the request unchanged when there are no custom headers`() { + every { mockCustomHeadersProvider.getCustomHeaders(url = any()) } returns emptyMap() + val originalRequest = Request.Builder() + .url("https://api.pwnedpasswords.com/range/12345") + .build() + val chain = FakeInterceptorChain(originalRequest) + + val response = interceptor.intercept(chain) + + assertEquals(originalRequest, response.request) + assertNull(response.request.header("CF-Access-Client-Secret")) + } +} diff --git a/network/src/test/kotlin/com/bitwarden/network/retrofit/RetrofitsTest.kt b/network/src/test/kotlin/com/bitwarden/network/retrofit/RetrofitsTest.kt index 145784da10d..f909537eea2 100644 --- a/network/src/test/kotlin/com/bitwarden/network/retrofit/RetrofitsTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/retrofit/RetrofitsTest.kt @@ -3,6 +3,7 @@ package com.bitwarden.network.retrofit import com.bitwarden.network.interceptor.AuthTokenManager import com.bitwarden.network.interceptor.BaseUrlInterceptors import com.bitwarden.network.interceptor.CookieInterceptor +import com.bitwarden.network.interceptor.CustomHeadersInterceptor import com.bitwarden.network.interceptor.HeadersInterceptor import com.bitwarden.network.interceptor.PermissionInterceptor import com.bitwarden.network.model.NetworkResult @@ -54,6 +55,9 @@ class RetrofitsTest { private val cookieInterceptor = mockk { mockIntercept { isCookieInterceptorCalled = true } } + private val customHeadersInterceptor = mockk { + mockIntercept { isCustomHeadersInterceptorCalled = true } + } private val permissionInterceptor = mockk { mockIntercept { isPermissionInterceptorCalled = true } } @@ -72,6 +76,7 @@ class RetrofitsTest { authTokenManager = authTokenManager, baseUrlInterceptors = baseUrlInterceptors, cookieInterceptor = cookieInterceptor, + customHeadersInterceptor = customHeadersInterceptor, permissionInterceptor = permissionInterceptor, headersInterceptor = headersInterceptors, certificateProvider = certificateProvider, @@ -81,6 +86,7 @@ class RetrofitsTest { private var isAuthInterceptorCalled = false private var isApiInterceptorCalled = false private var isCookieInterceptorCalled = false + private var isCustomHeadersInterceptorCalled = false private var isPermissionInterceptorCalled = false private var isHeadersInterceptorCalled = false private var isIdentityInterceptorCalled = false @@ -186,6 +192,7 @@ class RetrofitsTest { assertTrue(isAuthInterceptorCalled) assertTrue(isApiInterceptorCalled) assertTrue(isCookieInterceptorCalled) + assertTrue(isCustomHeadersInterceptorCalled) assertTrue(isPermissionInterceptorCalled) assertTrue(isHeadersInterceptorCalled) assertFalse(isIdentityInterceptorCalled) @@ -206,6 +213,7 @@ class RetrofitsTest { assertTrue(isAuthInterceptorCalled) assertFalse(isApiInterceptorCalled) assertTrue(isCookieInterceptorCalled) + assertTrue(isCustomHeadersInterceptorCalled) assertTrue(isPermissionInterceptorCalled) assertTrue(isHeadersInterceptorCalled) assertFalse(isIdentityInterceptorCalled) @@ -226,6 +234,7 @@ class RetrofitsTest { assertFalse(isAuthInterceptorCalled) assertTrue(isApiInterceptorCalled) assertTrue(isCookieInterceptorCalled) + assertTrue(isCustomHeadersInterceptorCalled) assertTrue(isPermissionInterceptorCalled) assertTrue(isHeadersInterceptorCalled) assertFalse(isIdentityInterceptorCalled) @@ -246,6 +255,7 @@ class RetrofitsTest { assertFalse(isAuthInterceptorCalled) assertFalse(isApiInterceptorCalled) assertTrue(isCookieInterceptorCalled) + assertTrue(isCustomHeadersInterceptorCalled) assertTrue(isPermissionInterceptorCalled) assertTrue(isHeadersInterceptorCalled) assertTrue(isIdentityInterceptorCalled) @@ -266,6 +276,7 @@ class RetrofitsTest { assertFalse(isAuthInterceptorCalled) assertFalse(isApiInterceptorCalled) assertFalse(isCookieInterceptorCalled) + assertTrue(isCustomHeadersInterceptorCalled) assertTrue(isPermissionInterceptorCalled) assertTrue(isHeadersInterceptorCalled) assertFalse(isIdentityInterceptorCalled) @@ -288,6 +299,7 @@ class RetrofitsTest { assertTrue(isAuthInterceptorCalled) assertFalse(isApiInterceptorCalled) assertTrue(isCookieInterceptorCalled) + assertTrue(isCustomHeadersInterceptorCalled) assertTrue(isPermissionInterceptorCalled) assertTrue(isHeadersInterceptorCalled) assertFalse(isIdentityInterceptorCalled) @@ -309,6 +321,7 @@ class RetrofitsTest { assertFalse(isAuthInterceptorCalled) assertFalse(isApiInterceptorCalled) assertTrue(isCookieInterceptorCalled) + assertTrue(isCustomHeadersInterceptorCalled) assertTrue(isPermissionInterceptorCalled) assertTrue(isHeadersInterceptorCalled) assertFalse(isIdentityInterceptorCalled) @@ -329,6 +342,7 @@ class RetrofitsTest { authTokenManager = authTokenManager, baseUrlInterceptors = baseUrlInterceptors, cookieInterceptor = cookieInterceptor, + customHeadersInterceptor = customHeadersInterceptor, headersInterceptor = headersInterceptors, certificateProvider = certificateProvider, permissionInterceptor = permissionInterceptor, diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index bf77bb40cd9..65974a46d18 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -953,6 +953,11 @@ Do you want to switch to this account? Certificate password incorrect Invalid certificate chain Using a system certificate is less secure than storing the certificate with Bitwarden. Continuing will display a list of available system certificates if one is already installed. + Custom headers + Add header + Remove header + Custom headers are sent with every request to your server. Use this if your server sits behind a proxy that requires additional headers, such as Cloudflare Access. + One or more custom headers are invalid. Header names and values may only contain visible ASCII characters, and names must be unique. Link Passkey operation failed because host URL is not present in request. Passkey operation failed because app signature is invalid.