Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +23,9 @@ class TokenAuthenticator @Inject constructor(
private val prefs: PreferencesRepository,
private val moshi: Moshi,
private val lazyClient: Lazy<OkHttpClient>,
// 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<AccountDataWiper>,
) : Authenticator {

// Synchronized to prevent concurrent refresh races: if two 401s arrive at once,
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -40,14 +41,21 @@ class TrustedDeviceCookieJar @Inject constructor(
) : CookieJar {

override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
// 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)
}
}

override fun loadForRequest(url: HttpUrl): List<Cookie> {
// 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()
Expand All @@ -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/"
Expand Down
10 changes: 6 additions & 4 deletions sheaf/app/src/main/java/systems/lupine/sheaf/di/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PreferencesRepository>().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<Request>()
val chain = mockk<Interceptor.Chain>()
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"))
}
}
Loading
Loading