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 6e40b7167f..9ca5654b41 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 @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.platform.glide import android.content.Context +import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.network.ssl.createMtlsOkHttpClient import com.bumptech.glide.Glide import com.bumptech.glide.Registry @@ -9,6 +10,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.network.NetworkCookieManager import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -24,6 +26,7 @@ import java.io.InputStream * * The configuration mirrors the SSL setup used in RetrofitsImpl for API calls. */ +@OmitFromCoverage @GlideModule class BitwardenAppGlideModule : AppGlideModule() { @@ -37,6 +40,11 @@ class BitwardenAppGlideModule : AppGlideModule() { * Provides access to the [CertificateManager] for mTLS certificate management. */ fun certificateManager(): CertificateManager + + /** + * Provides access to the [NetworkCookieManager] for cookie-based authentication. + */ + fun networkCookieManager(): NetworkCookieManager } override fun registerComponents(context: Context, glide: Glide, registry: Registry) { @@ -46,12 +54,20 @@ class BitwardenAppGlideModule : AppGlideModule() { entryPoint = BitwardenGlideEntryPoint::class.java, ) val certificateManager = entryPoint.certificateManager() + val networkCookieManager = entryPoint.networkCookieManager() - // Register OkHttpUrlLoader that uses our mTLS OkHttpClient + // Build OkHttpClient with mTLS and cookie support + val client = certificateManager + .createMtlsOkHttpClient() + .newBuilder() + .addNetworkInterceptor(GlideCookieInterceptor(networkCookieManager)) + .build() + + // Register OkHttpUrlLoader that uses our mTLS + cookie OkHttpClient registry.replace( GlideUrl::class.java, InputStream::class.java, - OkHttpUrlLoader.Factory(certificateManager.createMtlsOkHttpClient()), + OkHttpUrlLoader.Factory(client), ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/glide/GlideCookieInterceptor.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/glide/GlideCookieInterceptor.kt new file mode 100644 index 0000000000..0211fbbc96 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/glide/GlideCookieInterceptor.kt @@ -0,0 +1,49 @@ +package com.x8bit.bitwarden.ui.platform.glide + +import com.bitwarden.network.exception.CookieRedirectException +import com.bitwarden.network.provider.CookieProvider +import okhttp3.Interceptor +import okhttp3.Response + +private const val HEADER_COOKIE = "Cookie" +private const val HTTP_302 = 302 + +/** + * Interceptor that attaches cookies to Glide image requests for enterprise environments + * requiring cookie-based authentication. + * + * Unlike [com.bitwarden.network.interceptor.CookieInterceptor], this interceptor does not + * trigger cookie acquisition. It throws [CookieRedirectException] on HTTP 302 responses + * to prevent Glide from following redirects and caching invalid content. + * + * @property cookieProvider Provider for retrieving cookies by hostname. + */ +class GlideCookieInterceptor( + private val cookieProvider: CookieProvider, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val hostname = originalRequest.url.host + val cookieHeader = cookieProvider + .getCookies(hostname) + .joinToString("; ") { "${it.name}=${it.value}" } + + if (cookieHeader.isEmpty()) return chain.proceed(originalRequest).also(::check302) + + val request = originalRequest + .newBuilder() + .header(HEADER_COOKIE, cookieHeader) + .build() + return chain.proceed(request).also(::check302) + } + + private fun check302(response: Response) { + if (response.code == HTTP_302) { + response.close() + throw CookieRedirectException( + hostname = response.request.url.host, + ) + } + } +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModuleTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModuleTest.kt index 9ef1865a48..b8828f4520 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModuleTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModuleTest.kt @@ -44,4 +44,17 @@ class BitwardenAppGlideModuleTest { assertTrue(hasCertificateManagerMethod) } + + @Test + fun `BitwardenGlideEntryPoint should declare networkCookieManager method`() { + val entryPointInterface = BitwardenAppGlideModule::class.java + .declaredClasses + .firstOrNull { it.simpleName == "BitwardenGlideEntryPoint" } + + val methods = requireNotNull(entryPointInterface).declaredMethods + val hasNetworkCookieManagerMethod = + methods.any { it.name == "networkCookieManager" } + + assertTrue(hasNetworkCookieManagerMethod) + } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/glide/GlideCookieInterceptorTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/glide/GlideCookieInterceptorTest.kt new file mode 100644 index 0000000000..034c50fd96 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/glide/GlideCookieInterceptorTest.kt @@ -0,0 +1,163 @@ +package com.x8bit.bitwarden.ui.platform.glide + +import com.bitwarden.network.exception.CookieRedirectException +import com.bitwarden.network.interceptor.FakeInterceptorChain +import com.bitwarden.network.model.NetworkCookie +import com.bitwarden.network.provider.CookieProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class GlideCookieInterceptorTest { + + private val mockCookieProvider: CookieProvider = mockk() + + private val interceptor = GlideCookieInterceptor( + cookieProvider = mockCookieProvider, + ) + + @Test + fun `intercept should proceed without cookie header when no cookies available`() { + val originalRequest = Request.Builder() + .url("https://vault.bitwarden.com/icons/icon.png") + .build() + val chain = FakeInterceptorChain(originalRequest) + + every { + mockCookieProvider.getCookies("vault.bitwarden.com") + } returns emptyList() + + val response = interceptor.intercept(chain) + + assertEquals(originalRequest, response.request) + assertNull(response.request.header("Cookie")) + } + + @Test + fun `intercept should attach single cookie correctly`() { + val originalRequest = Request.Builder() + .url("https://vault.bitwarden.com/icons/icon.png") + .build() + val chain = FakeInterceptorChain(originalRequest) + + every { + mockCookieProvider.getCookies("vault.bitwarden.com") + } returns listOf( + NetworkCookie(name = "awselb", value = "session123"), + ) + + val response = interceptor.intercept(chain) + + assertEquals("awselb=session123", response.request.header("Cookie")) + } + + @Test + fun `intercept should attach multiple cookies in correct format`() { + val originalRequest = Request.Builder() + .url("https://vault.bitwarden.com/icons/icon.png") + .build() + val chain = FakeInterceptorChain(originalRequest) + + every { + mockCookieProvider.getCookies("vault.bitwarden.com") + } returns listOf( + NetworkCookie(name = "awselb", value = "session123"), + NetworkCookie(name = "awselbcors", value = "cors456"), + ) + + val response = interceptor.intercept(chain) + + assertEquals( + "awselb=session123; awselbcors=cors456", + response.request.header("Cookie"), + ) + } + + @Test + fun `intercept should throw CookieRedirectException on 302 response without cookies`() { + val originalRequest = Request.Builder() + .url("https://vault.bitwarden.com/icons/icon.png") + .build() + + val redirectResponse = Response.Builder() + .code(302) + .message("Found") + .protocol(Protocol.HTTP_1_1) + .request(originalRequest) + .header("Location", "https://idp.example.com/auth") + .build() + + val chain = FakeInterceptorChain( + request = originalRequest, + responseProvider = { redirectResponse }, + ) + + every { + mockCookieProvider.getCookies("vault.bitwarden.com") + } returns emptyList() + + val exception = assertThrows { + interceptor.intercept(chain) + } + + assertEquals("vault.bitwarden.com", exception.hostname) + } + + @Test + fun `intercept should throw CookieRedirectException on 302 response with cookies`() { + val originalRequest = Request.Builder() + .url("https://vault.bitwarden.com/icons/icon.png") + .build() + + val redirectResponse = Response.Builder() + .code(302) + .message("Found") + .protocol(Protocol.HTTP_1_1) + .request(originalRequest) + .header("Location", "https://idp.example.com/auth") + .build() + + val chain = FakeInterceptorChain( + request = originalRequest, + responseProvider = { redirectResponse }, + ) + + every { + mockCookieProvider.getCookies("vault.bitwarden.com") + } returns listOf( + NetworkCookie(name = "awselb", value = "session123"), + ) + + val exception = assertThrows { + interceptor.intercept(chain) + } + + assertEquals("vault.bitwarden.com", exception.hostname) + } + + @Test + fun `intercept should not call needsBootstrap or acquireCookies`() { + val originalRequest = Request.Builder() + .url("https://vault.bitwarden.com/icons/icon.png") + .build() + val chain = FakeInterceptorChain(originalRequest) + + every { + mockCookieProvider.getCookies("vault.bitwarden.com") + } returns emptyList() + + interceptor.intercept(chain) + + verify(exactly = 0) { + mockCookieProvider.needsBootstrap(any()) + mockCookieProvider.acquireCookies(any()) + } + } +}