From 0958695deb3a8eafd9419921a25943c17d893d2f Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:35:43 -0400 Subject: [PATCH] Two credential-exposure problems, both confirmed from a review. Origin-blind credentials. AuthInterceptor attached the bearer token and the Cloudflare Access secrets to every request regardless of host, and the Coil image client is cloned from the API client, so it inherited that interceptor. Image URLs can point anywhere: a member avatar or an image embedded in a bio can be hosted on an external server, and that server received the user's live session. The trusted-device cookie was host-blind too (it bound to the request host and gated only on the /v1/auth/ path). Fix: credentials are attached only to the instance's own origins, the API base URL and the configured file CDN, via a shared origin check. The cookie is scoped to the API origin. External image hosts now get the X-Sheaf-Client header and nothing else. The image client keeps the debug SSL-trust config (it is still cloned from the API client) but no longer re-adds the user-agent interceptor it already inherits. ran only on the standard login and logout paths. Three teardowns bypassed it: a registration that skips email verification (committed a session directly without finishAuth), a server-URL change while signed in (cleared nothing, so the old instance's tokens/cache/queue survived and its bearer would ride to the new host), and a forced logout on refresh-token expiry (cleared tokens only). the session; a change to the same origin does not. Tests: origin matching (trusted/untrusted/lookalike/scheme/port/path-prefix), the interceptor's actual header output for API vs CDN vs external hosts, and the cookie's host+path scoping. --- CHANGELOG.md | 14 +++ .../lupine/sheaf/data/api/AuthInterceptor.kt | 34 +++++-- .../lupine/sheaf/data/api/CredentialOrigin.kt | 54 +++++++++++ .../sheaf/data/api/TokenAuthenticator.kt | 12 ++- .../sheaf/data/api/TrustedDeviceCookieJar.kt | 11 +++ .../systems/lupine/sheaf/di/NetworkModule.kt | 10 +- .../lupine/sheaf/ui/auth/AuthViewModel.kt | 21 +++- .../sheaf/ui/settings/SettingsViewModel.kt | 15 ++- .../sheaf/data/api/AuthInterceptorTest.kt | 81 ++++++++++++++++ .../sheaf/data/api/CredentialOriginTest.kt | 95 +++++++++++++++++++ .../data/api/TrustedDeviceCookieJarTest.kt | 23 ++++- 11 files changed, 353 insertions(+), 17 deletions(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/data/api/CredentialOrigin.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/data/api/AuthInterceptorTest.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/data/api/CredentialOriginTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce63b9..a820ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). ### Security +- **Your login credentials only ever go to your own server now.** The app used to + attach your session token (and any Cloudflare Access secrets) to every network + request, including image loads. An avatar or an image embedded in a bio that was + hosted on some other server would therefore receive your live credentials. They + are now sent only to your configured instance and its image host, never to any + other server. The "remember this device" cookie is scoped the same way. + +- **Switching servers, or being signed out, fully clears the previous account.** + Changing the server address while signed in now ends the old session and clears + its cached data and pending offline actions instead of leaving them behind; + a registration that skips email verification, and a forced sign-out when your + session expires, now clear the same data. Previously some of these paths left a + prior account's cache or offline queue on the device. + - **Your data no longer goes into Android's cloud backup.** The phone and watch apps now opt out of backup and device-to-device transfer entirely. Previously the local cache (members, groups, front history, messages), the offline switch diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/AuthInterceptor.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/AuthInterceptor.kt index 2f1d837..dbadcef 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/AuthInterceptor.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/AuthInterceptor.kt @@ -28,17 +28,33 @@ class AuthInterceptor @Inject constructor( private val clientHeader = "Sheaf Android/${BuildConfig.VERSION_NAME}" override fun intercept(chain: Interceptor.Chain): Response { - val token = pendingToken ?: runBlocking { prefs.accessToken.firstOrNull() } - val cfClientId = runBlocking { prefs.cfClientId.firstOrNull() } - val cfClientSecret = runBlocking { prefs.cfClientSecret.firstOrNull() } - val builder = chain.request().newBuilder() + val request = chain.request() + val builder = request.newBuilder() .addHeader("X-Sheaf-Client", clientHeader) - if (token != null) { - builder.addHeader("Authorization", "Bearer $token") + + // Credentials go only to the instance's own origins. The Coil image + // client shares this interceptor, and image URLs can point at an + // external host (a remote avatar, an image embedded in a bio), so an + // unconditional bearer / CF-Access header would hand the user's live + // session to whatever server that image lives on. + val trusted = runBlocking { + isTrustedCredentialOrigin( + request.url, + prefs.baseUrl.firstOrNull(), + prefs.fileCdnBase.firstOrNull(), + ) } - if (cfClientId != null && cfClientSecret != null) { - builder.addHeader("CF-Access-Client-Id", cfClientId) - builder.addHeader("CF-Access-Client-Secret", cfClientSecret) + if (trusted) { + val token = pendingToken ?: runBlocking { prefs.accessToken.firstOrNull() } + if (token != null) { + builder.addHeader("Authorization", "Bearer $token") + } + val cfClientId = runBlocking { prefs.cfClientId.firstOrNull() } + val cfClientSecret = runBlocking { prefs.cfClientSecret.firstOrNull() } + if (cfClientId != null && cfClientSecret != null) { + builder.addHeader("CF-Access-Client-Id", cfClientId) + builder.addHeader("CF-Access-Client-Secret", cfClientSecret) + } } return chain.proceed(builder.build()) } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/CredentialOrigin.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/CredentialOrigin.kt new file mode 100644 index 0000000..a99fcdd --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/CredentialOrigin.kt @@ -0,0 +1,54 @@ +package systems.lupine.sheaf.data.api + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * Origin matching for deciding where credentials may be sent. + * + * The API auth stack (bearer token, Cloudflare Access secrets, the + * trusted-device cookie) used to be attached host-blind, and the Coil image + * client is cloned from the API client, so a member avatar or a bio-embedded + * image hosted on an external server received the user's live credentials. The + * interceptors now gate on origin: credentials go only to the instance's own + * hosts, never to whatever host an image URL happens to point at. + */ + +private fun normalizedOrigin(configured: String?): HttpUrl? { + val raw = configured?.trim()?.trimEnd('/')?.ifBlank { null } ?: return null + // The CDN base can arrive scheme-less from the instance config; assume https + // so a bare host still parses to a comparable origin. + val withScheme = if (raw.contains("://")) raw else "https://$raw" + return withScheme.toHttpUrlOrNull() +} + +/** True when [url] has the same scheme, host and port as [configured]. Path is ignored. */ +internal fun originMatches(url: HttpUrl, configured: String?): Boolean { + val base = normalizedOrigin(configured) ?: return false + return url.scheme == base.scheme && + url.host.equals(base.host, ignoreCase = true) && + url.port == base.port +} + +/** + * Credentials may be sent to the API base origin and to the instance's + * configured file CDN origin (both are infrastructure the user configured or + * received from the instance's own config), and to nothing else. + */ +internal fun isTrustedCredentialOrigin(url: HttpUrl, baseUrl: String?, fileCdnBase: String?): Boolean = + originMatches(url, baseUrl) || originMatches(url, fileCdnBase) + +/** + * True when two configured base URLs point at the same origin. Used to decide + * whether changing the server URL is actually switching instances (and so must + * drop the old session) or just a cosmetic edit of the same one. Two blank/unset + * values count as the same. + */ +internal fun sameConfiguredOrigin(a: String?, b: String?): Boolean { + val ao = normalizedOrigin(a) + val bo = normalizedOrigin(b) + if (ao == null || bo == null) return ao == null && bo == null + return ao.scheme == bo.scheme && + ao.host.equals(bo.host, ignoreCase = true) && + ao.port == bo.port +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TokenAuthenticator.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TokenAuthenticator.kt index ac0d525..0a0b2a5 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TokenAuthenticator.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TokenAuthenticator.kt @@ -13,6 +13,7 @@ import okhttp3.Response import okhttp3.Route import systems.lupine.sheaf.data.model.TokenRefresh import systems.lupine.sheaf.data.model.TokenResponse +import systems.lupine.sheaf.data.repository.AccountDataWiper import systems.lupine.sheaf.data.repository.PreferencesRepository import javax.inject.Inject import javax.inject.Singleton @@ -22,6 +23,9 @@ class TokenAuthenticator @Inject constructor( private val prefs: PreferencesRepository, private val moshi: Moshi, private val lazyClient: Lazy, + // Lazy so the OkHttp graph doesn't have to build the Room-backed wiper up + // front; it's only needed on the rare forced-logout path. + private val accountDataWiper: Lazy, ) : Authenticator { // Synchronized to prevent concurrent refresh races: if two 401s arrive at once, @@ -77,7 +81,13 @@ class TokenAuthenticator @Inject constructor( // Only a 401 means the refresh token is genuinely invalid/expired. // Any other failure (5xx, 503, etc.) is transient — don't destroy the session. if (refreshResponse.code == 401) { - runBlocking { prefs.clearTokens() } + // Forced logout: clear the tokens AND wipe the account's cache + // and offline queue, so the next account signed in on this device + // can't inherit them (clearTokens alone left both behind). + runBlocking { + prefs.clearTokens() + accountDataWiper.get().wipe() + } } return null } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJar.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJar.kt index b50cc97..3cbd951 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJar.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJar.kt @@ -1,5 +1,6 @@ package systems.lupine.sheaf.data.api +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.runBlocking import okhttp3.Cookie import okhttp3.CookieJar @@ -40,6 +41,9 @@ class TrustedDeviceCookieJar @Inject constructor( ) : CookieJar { override fun saveFromResponse(url: HttpUrl, cookies: List) { + // Only store the cookie when it came from our own API origin, so a + // response from some other host can't seed it. + if (!isApiOrigin(url)) return val tdc = cookies.firstOrNull { it.name == COOKIE_NAME } ?: return runBlocking { prefs.saveTrustedDeviceCookie(tdc.value, tdc.expiresAt) @@ -47,7 +51,11 @@ class TrustedDeviceCookieJar @Inject constructor( } override fun loadForRequest(url: HttpUrl): List { + // Host-scope as well as path-scope: the trusted-device token is bound to + // the instance that issued it and must not ride along to another host + // (e.g. after the user points the app at a different server). if (!url.encodedPath.startsWith(COOKIE_PATH)) return emptyList() + if (!isApiOrigin(url)) return emptyList() val value = prefs.trustedDeviceCookieBlocking() ?: return emptyList() return listOf( Cookie.Builder() @@ -59,6 +67,9 @@ class TrustedDeviceCookieJar @Inject constructor( ) } + private fun isApiOrigin(url: HttpUrl): Boolean = + originMatches(url, runBlocking { prefs.baseUrl.firstOrNull() }) + private companion object { const val COOKIE_NAME = "sheaf_trusted_device" const val COOKIE_PATH = "/v1/auth/" diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/di/NetworkModule.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/di/NetworkModule.kt index 47b70d8..365f67e 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/di/NetworkModule.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/di/NetworkModule.kt @@ -126,11 +126,13 @@ object NetworkModule { @ApplicationContext context: Context, okHttpClient: OkHttpClient, relativeUrlInterceptor: RelativeUrlInterceptor, - userAgentInterceptor: UserAgentInterceptor, ): ImageLoader { - val imageClient = okHttpClient.newBuilder() - .addInterceptor(userAgentInterceptor) - .build() + // Cloned from the API client so it keeps the debug SSL-trust config for + // local dev servers. It also inherits AuthInterceptor, but that only + // attaches credentials to the instance's own origins now, so an image + // fetched from an external host carries none. (User-Agent and the + // cookie jar are inherited from the clone, so we don't re-add them.) + val imageClient = okHttpClient.newBuilder().build() return ImageLoader.Builder(context) .okHttpClient(imageClient) .components { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/AuthViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/AuthViewModel.kt index 1a27907..ac0cae7 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/AuthViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/AuthViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import systems.lupine.sheaf.data.api.AltchaSolver import systems.lupine.sheaf.data.api.AuthInterceptor import systems.lupine.sheaf.data.api.SheafApiService +import systems.lupine.sheaf.data.api.sameConfiguredOrigin +import kotlinx.coroutines.flow.firstOrNull import systems.lupine.sheaf.data.model.AuthConfig import systems.lupine.sheaf.data.model.TokenResponse import systems.lupine.sheaf.data.model.UserLogin @@ -129,7 +131,19 @@ class AuthViewModel @Inject constructor( private var pendingCaptcha: String? = null fun saveBaseUrl(url: String) { - viewModelScope.launch { prefs.saveBaseUrl(url) } + viewModelScope.launch { + val previous = prefs.baseUrl.firstOrNull() + prefs.saveBaseUrl(url) + // Switching to a different instance must drop any session bound to + // the old one: otherwise the previous instance's tokens, cache and + // offline queue survive against the new host (and its bearer would + // ride to the new server). + if (!sameConfiguredOrigin(previous, url)) { + authInterceptor.pendingToken = null + prefs.clearTokens() + accountDataWiper.wipe() + } + } } fun saveCfTokens(clientId: String, clientSecret: String) { @@ -246,6 +260,11 @@ class AuthViewModel @Inject constructor( pendingRefreshToken = tokens.refreshToken _uiState.value = AuthUiState.AwaitingEmailVerification() } else { + // This branch commits a session directly instead of via + // finishAuth(), so wipe here too: without it a prior + // account's cache and offline queue survive into the new + // account (finishAuth is the only other path that wipes). + accountDataWiper.wipe() prefs.saveTokens(tokens.accessToken, tokens.refreshToken) runCatching { PhoneDataLayerService.pushWatchCredentials( diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsViewModel.kt index d6ba3f0..fe5207f 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsViewModel.kt @@ -83,6 +83,8 @@ class SettingsViewModel @Inject constructor( private val prefs: PreferencesRepository, private val notificationHelper: FrontNotificationHelper, private val watchSession: WatchSessionRepository, + private val accountDataWiper: systems.lupine.sheaf.data.repository.AccountDataWiper, + private val authInterceptor: systems.lupine.sheaf.data.api.AuthInterceptor, @dagger.hilt.android.qualifiers.ApplicationContext private val appContext: android.content.Context, ) : ViewModel() { @@ -164,7 +166,18 @@ class SettingsViewModel @Inject constructor( } fun saveBaseUrl(url: String) { - viewModelScope.launch { prefs.saveBaseUrl(url) } + viewModelScope.launch { + val previous = prefs.baseUrl.firstOrNull() + prefs.saveBaseUrl(url) + // Changing the server here means switching instances while signed in. + // Drop the old session so its tokens, cache and offline queue can't + // carry over to (or leak toward) the new host. + if (!systems.lupine.sheaf.data.api.sameConfiguredOrigin(previous, url)) { + authInterceptor.pendingToken = null + prefs.clearTokens() + accountDataWiper.wipe() + } + } } fun saveTheme(mode: String) { diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/AuthInterceptorTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/AuthInterceptorTest.kt new file mode 100644 index 0000000..ac600ce --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/AuthInterceptorTest.kt @@ -0,0 +1,81 @@ +package systems.lupine.sheaf.data.api + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.flowOf +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import systems.lupine.sheaf.data.repository.PreferencesRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The end-to-end check on the credential leak: the bearer and Cloudflare Access + * headers must appear only on requests to the instance's own origins, never on a + * request to an external image host (the image client shares this interceptor). + */ +class AuthInterceptorTest { + + private val prefs = mockk().apply { + every { baseUrl } returns flowOf("https://app.sheaf.sh") + every { fileCdnBase } returns flowOf("https://cdn.sheaf.sh") + every { accessToken } returns flowOf("access-tok") + every { cfClientId } returns flowOf("cf-id") + every { cfClientSecret } returns flowOf("cf-secret") + } + private val interceptor = AuthInterceptor(prefs) + + /** Runs the interceptor against [urlString] and returns the outgoing request. */ + private fun send(urlString: String): Request { + val request = Request.Builder().url(urlString).build() + val sent = slot() + val chain = mockk() + every { chain.request() } returns request + every { chain.proceed(capture(sent)) } answers { + Response.Builder() + .request(sent.captured) + .protocol(Protocol.HTTP_1_1) + .code(200).message("OK") + .build() + } + interceptor.intercept(chain) + return sent.captured + } + + @Test fun `API requests carry the bearer and CF headers`() { + val sent = send("https://app.sheaf.sh/v1/members") + assertEquals("Bearer access-tok", sent.header("Authorization")) + assertEquals("cf-id", sent.header("CF-Access-Client-Id")) + assertEquals("cf-secret", sent.header("CF-Access-Client-Secret")) + } + + @Test fun `CDN image requests are still credentialed`() { + val sent = send("https://cdn.sheaf.sh/avatars/x.png") + assertEquals("Bearer access-tok", sent.header("Authorization")) + assertEquals("cf-id", sent.header("CF-Access-Client-Id")) + } + + @Test fun `an external image host receives no credentials`() { + val sent = send("https://images.example.com/remote-avatar.png") + assertNull(sent.header("Authorization")) + assertNull(sent.header("CF-Access-Client-Id")) + assertNull(sent.header("CF-Access-Client-Secret")) + } + + @Test fun `the client header is always sent, even to external hosts`() { + // Not a credential; safe (and useful) everywhere. + val sent = send("https://images.example.com/remote-avatar.png") + assertEquals("Sheaf Android/${systems.lupine.sheaf.BuildConfig.VERSION_NAME}", sent.header("X-Sheaf-Client")) + } + + @Test fun `the in-memory pending token is used during intermediate auth`() { + every { prefs.accessToken } returns flowOf(null) + interceptor.pendingToken = "pending-tok" + val sent = send("https://app.sheaf.sh/v1/users/me") + assertEquals("Bearer pending-tok", sent.header("Authorization")) + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/CredentialOriginTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/CredentialOriginTest.kt new file mode 100644 index 0000000..fedc549 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/CredentialOriginTest.kt @@ -0,0 +1,95 @@ +package systems.lupine.sheaf.data.api + +import okhttp3.HttpUrl.Companion.toHttpUrl +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Credentials (bearer, CF-Access secrets, the trusted-device cookie) may reach + * only the instance's own origins. This is what stops the shared image client + * from handing the user's session to whatever host an avatar or bio-embedded + * image points at. + */ +class CredentialOriginTest { + + private fun url(u: String) = u.toHttpUrl() + + private val api = "https://app.sheaf.sh" + private val cdn = "https://cdn.sheaf.sh" + + @Test fun `a request to the API origin is trusted`() { + assertTrue(isTrustedCredentialOrigin(url("https://app.sheaf.sh/v1/members"), api, cdn)) + } + + @Test fun `a request to the configured CDN origin is trusted`() { + assertTrue(isTrustedCredentialOrigin(url("https://cdn.sheaf.sh/avatars/x.png"), api, cdn)) + } + + @Test fun `an external host is never trusted`() { + assertFalse(isTrustedCredentialOrigin(url("https://evil.example.com/x.png"), api, cdn)) + assertFalse(isTrustedCredentialOrigin(url("https://imgur.com/a.png"), api, cdn)) + } + + @Test fun `a lookalike host is not trusted`() { + assertFalse(isTrustedCredentialOrigin(url("https://app.sheaf.sh.evil.com/x"), api, cdn)) + assertFalse(isTrustedCredentialOrigin(url("https://notapp.sheaf.sh/x"), api, cdn)) + } + + @Test fun `scheme must match`() { + // http vs https is a different origin; a downgrade must not carry creds. + assertFalse(isTrustedCredentialOrigin(url("http://app.sheaf.sh/v1/members"), api, cdn)) + } + + @Test fun `port must match`() { + assertFalse(isTrustedCredentialOrigin(url("https://app.sheaf.sh:8443/v1/members"), api, cdn)) + } + + @Test fun `the path prefix on the base URL is ignored for origin matching`() { + val based = "https://example.org/sheaf" + assertTrue(isTrustedCredentialOrigin(url("https://example.org/v1/members"), based, null)) + assertTrue(isTrustedCredentialOrigin(url("https://example.org/sheaf/v1/members"), based, null)) + } + + @Test fun `host comparison is case-insensitive`() { + assertTrue(isTrustedCredentialOrigin(url("https://APP.sheaf.sh/v1/members"), api, cdn)) + } + + @Test fun `no CDN configured means only the API origin is trusted`() { + assertTrue(isTrustedCredentialOrigin(url("https://app.sheaf.sh/v1/members"), api, null)) + assertFalse(isTrustedCredentialOrigin(url("https://cdn.sheaf.sh/x"), api, null)) + } + + @Test fun `a scheme-less CDN base is assumed https`() { + assertTrue(isTrustedCredentialOrigin(url("https://cdn.sheaf.sh/x"), api, "cdn.sheaf.sh")) + } + + @Test fun `nothing is trusted when no base is configured`() { + assertFalse(isTrustedCredentialOrigin(url("https://app.sheaf.sh/v1/members"), null, null)) + assertFalse(isTrustedCredentialOrigin(url("https://app.sheaf.sh/v1/members"), "", null)) + } + + // ── Server-change detection ───────────────────────────────────────────── + + @Test fun `the same origin is recognised despite path or trailing slash`() { + assertTrue(sameConfiguredOrigin("https://app.sheaf.sh", "https://app.sheaf.sh/")) + assertTrue(sameConfiguredOrigin("https://example.org/sheaf", "https://example.org/other")) + assertTrue(sameConfiguredOrigin("app.sheaf.sh", "https://app.sheaf.sh")) + } + + @Test fun `a different host is a different origin`() { + assertFalse(sameConfiguredOrigin("https://app.sheaf.sh", "https://other.example.org")) + } + + @Test fun `first-time setup counts as a change`() { + // previous is unset, new is a real server: must be treated as a switch + // so the (empty) teardown runs and nothing stale is assumed. + assertFalse(sameConfiguredOrigin(null, "https://app.sheaf.sh")) + assertFalse(sameConfiguredOrigin("", "https://app.sheaf.sh")) + } + + @Test fun `two unset values are the same`() { + assertTrue(sameConfiguredOrigin(null, null)) + assertTrue(sameConfiguredOrigin("", " ")) + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJarTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJarTest.kt index fdd56f3..c6c9b2c 100644 --- a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJarTest.kt +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJarTest.kt @@ -4,6 +4,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf import okhttp3.Cookie import okhttp3.HttpUrl.Companion.toHttpUrl import systems.lupine.sheaf.data.repository.PreferencesRepository @@ -19,7 +20,10 @@ import kotlin.test.assertTrue */ class TrustedDeviceCookieJarTest { - private val prefs = mockk(relaxed = true) + private val prefs = mockk(relaxed = true).also { + // isApiOrigin reads the configured base URL to host-scope the cookie. + every { it.baseUrl } returns flowOf("https://app.sheaf.sh") + } private val jar = TrustedDeviceCookieJar(prefs) private val authUrl = "https://app.sheaf.sh/v1/auth/login".toHttpUrl() @@ -70,4 +74,21 @@ class TrustedDeviceCookieJarTest { assertTrue(jar.loadForRequest(authUrl).isEmpty()) } + + @Test fun `the cookie is not attached to a different host's auth path`() { + // Host-scoping: even a /v1/auth/ request to another instance (e.g. after + // the user changed servers) must not receive the old instance's token. + every { prefs.trustedDeviceCookieBlocking() } returns "tdc-1" + + assertTrue(jar.loadForRequest("https://other.example.org/v1/auth/login".toHttpUrl()).isEmpty()) + } + + @Test fun `a cookie from a different host is not stored`() { + jar.saveFromResponse( + "https://other.example.org/v1/auth/login".toHttpUrl(), + listOf(cookie("sheaf_trusted_device", "tdc-evil")), + ) + + coVerify(exactly = 0) { prefs.saveTrustedDeviceCookie(any(), any()) } + } }