diff --git a/android-shared/build.gradle.kts b/android-shared/build.gradle.kts index cfe5e475f..01a819e56 100644 --- a/android-shared/build.gradle.kts +++ b/android-shared/build.gradle.kts @@ -121,6 +121,7 @@ kotlin { implementation(libs.ktor.client.mock) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.json) + implementation(libs.okhttp.mockwebserver) } } } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerModule.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerModule.kt index 8347ec4ff..f785f62c1 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerModule.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerModule.kt @@ -34,10 +34,18 @@ val playerModule = module { // 401 on the refresh call can't loop back through MediaAuthInterceptor. single(named("player-refresh-okhttp")) { buildPlayerRefreshOkHttpClient() } - single { MediaAuthSession(tokenManager = get(), refreshClient = get(named("player-refresh-okhttp"))) } + single { + MediaAuthSession( + tokenManager = get(), + refreshClient = get(named("player-refresh-okhttp")), + cleartextOriginConsent = getOrNull(), + ) + } single { MediaAuthInterceptor(authSession = get()) } - single(PLAYER_TRANSPORT_OKHTTP_QUALIFIER) { buildPlayerOkHttpClient() } + single(PLAYER_TRANSPORT_OKHTTP_QUALIFIER) { + buildPlayerOkHttpClient(cleartextOriginConsent = getOrNull()) + } // Reader/download callers still consume the authenticated OkHttp client. // Media3 itself uses the raw pooled transport below and applies auth in a diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/io/LimitedStreams.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/io/LimitedStreams.kt new file mode 100644 index 000000000..7c086c484 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/io/LimitedStreams.kt @@ -0,0 +1,49 @@ +package org.siloserver.silo.common.io + +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +class ContentLimitExceeded( + val maxBytes: Long, + limitName: String = "content", +) : IOException("$limitName exceeds the allowed limit of $maxBytes") + +fun checkedLimitedByteCount( + currentBytes: Long, + additionalBytes: Long, + maxBytes: Long, + limitName: String = "content", +): Long { + require(currentBytes >= 0) { "currentBytes must not be negative" } + require(additionalBytes >= 0) { "additionalBytes must not be negative" } + require(maxBytes >= 0) { "maxBytes must not be negative" } + if (currentBytes > maxBytes || additionalBytes > maxBytes - currentBytes) { + throw ContentLimitExceeded(maxBytes, limitName) + } + return currentBytes + additionalBytes +} + +fun InputStream.copyToLimited( + out: OutputStream, + maxBytes: Long, + bufferSize: Int = DEFAULT_BUFFER_SIZE, +): Long { + require(maxBytes >= 0) { "maxBytes must not be negative" } + require(bufferSize > 0) { "bufferSize must be positive" } + var total = 0L + val buffer = ByteArray(bufferSize) + while (true) { + val read = read(buffer) + if (read < 0) return total + if (read == 0) { + val byte = read() + if (byte < 0) return total + total = checkedLimitedByteCount(total, 1, maxBytes) + out.write(byte) + continue + } + total = checkedLimitedByteCount(total, read.toLong(), maxBytes) + out.write(buffer, 0, read) + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/network/CleartextConsentStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/network/CleartextConsentStore.kt new file mode 100644 index 000000000..be3467d12 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/network/CleartextConsentStore.kt @@ -0,0 +1,61 @@ +package org.siloserver.silo.common.network + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import java.net.URI +import java.security.MessageDigest +import kotlinx.coroutines.flow.first +import org.siloserver.silo.network.CleartextOriginConsent + +interface CleartextConsentStore : CleartextOriginConsent { + suspend fun approve(origin: String) +} + +class DataStoreCleartextConsentStore( + private val dataStore: DataStore, +) : CleartextConsentStore { + constructor(context: Context) : this( + PreferenceDataStoreFactory.create { + context.preferencesDataStoreFile(DATA_STORE_NAME) + }, + ) + + override suspend fun isApproved(origin: String): Boolean { + val normalized = cleartextOrigin(origin) ?: return false + return originDigest(normalized) in dataStore.data.first()[APPROVED_ORIGIN_DIGESTS].orEmpty() + } + + override suspend fun approve(origin: String) { + val normalized = requireNotNull(cleartextOrigin(origin)) { + "Cleartext approval requires a valid HTTP origin" + } + val digest = originDigest(normalized) + dataStore.edit { preferences -> + preferences[APPROVED_ORIGIN_DIGESTS] = + preferences[APPROVED_ORIGIN_DIGESTS].orEmpty() + digest + } + } + + private companion object { + private const val DATA_STORE_NAME = "silo_cleartext_consent" + private val APPROVED_ORIGIN_DIGESTS = stringSetPreferencesKey("approved_origin_sha256") + + private fun originDigest(origin: String): String = + MessageDigest.getInstance("SHA-256") + .digest(origin.encodeToByteArray()) + .joinToString("") { byte -> "%02x".format(byte) } + } +} + +fun cleartextOrigin(url: String): String? = runCatching { + val uri = URI(url.trim()) + if (!uri.scheme.equals("http", ignoreCase = true)) return null + val host = uri.host?.lowercase()?.takeIf(String::isNotBlank) ?: return null + val port = if (uri.port == 80) -1 else uri.port + URI("http", null, host, port, null, null, null).toASCIIString() +}.getOrNull() diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/PairingAuthPort.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/PairingAuthPort.kt index 6e190d98d..c701bc202 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/PairingAuthPort.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/PairingAuthPort.kt @@ -6,6 +6,9 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.siloserver.silo.network.TokenManager import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.network.CleartextOriginConsent +import org.siloserver.silo.network.CleartextOriginNotApprovedException +import org.siloserver.silo.network.requiresApproval /** * Narrow commit seam for the pairing receiver after a candidate server approves device @@ -31,6 +34,7 @@ interface PairingAuthPort { class RegistryPairingAuthPort( private val tokenManager: TokenManager, private val serverRegistry: ServerRegistry, + private val cleartextOriginConsent: CleartextOriginConsent? = null, ) : PairingAuthPort { private val commitMutex = Mutex() @@ -42,6 +46,9 @@ class RegistryPairingAuthPort( expiresIn: Long, ) = withContext(NonCancellable) { commitMutex.withLock { + if (cleartextOriginConsent?.requiresApproval(serverUrl) == true) { + throw CleartextOriginNotApprovedException(serverUrl) + } val previousServerId = serverRegistry.activeServerId.value val serverId = serverRegistry.addOrUpdate(serverUrl, fetchedName = serverName) try { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/AuthenticatedDataSourceFactory.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/AuthenticatedDataSourceFactory.kt index edeefb7bb..d705771d9 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/AuthenticatedDataSourceFactory.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/AuthenticatedDataSourceFactory.kt @@ -10,10 +10,13 @@ import androidx.media3.datasource.DataSpec import androidx.media3.datasource.FileDataSource import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.TransferListener +import org.siloserver.silo.common.io.checkedLimitedByteCount import org.siloserver.silo.common.player.subtitle.normalizeSubripPayloadIfNeeded import java.io.ByteArrayOutputStream import java.io.IOException import kotlinx.coroutines.runBlocking +import org.siloserver.silo.network.isSameHttpOrigin +import org.siloserver.silo.network.CleartextOriginNotApprovedException /** * DataSource.Factory that resolves relative stream URLs against the server @@ -78,6 +81,9 @@ internal class RefreshingHttpDataSource( } override fun open(dataSpec: DataSpec): Long { + if (!runBlocking { authSession.isTransportApproved(dataSpec.uri.toString()) }) { + throw CleartextOriginNotApprovedException(dataSpec.uri.toString()) + } val guardEnabled = isResumableDirectPlayUri(dataSpec.uri) if (guardEnabled) { prepareEntityGuard(dataSpec.uri) @@ -88,7 +94,14 @@ internal class RefreshingHttpDataSource( return try { first.openWithGuards(dataSpec, failedSnapshot, guardEnabled) } catch (error: HttpDataSource.InvalidResponseCodeException) { - if (error.responseCode != 401 || !runBlocking { authSession.refreshIfStale(failedSnapshot) }) { + if ( + !shouldRefreshMediaRequest( + serverUrl = failedSnapshot.serverUrl, + requestUrl = dataSpec.uri.toString(), + responseCode = error.responseCode, + ) || + !runBlocking { authSession.refreshIfStale(failedSnapshot) } + ) { throw error } first.close() @@ -189,7 +202,12 @@ internal class RefreshingHttpDataSource( // headers as authoritative while filling only missing auth/profile // headers from the refreshable Silo session. .setHttpRequestHeaders( - mergeSessionAuthHeaders(snapshot.asRequestHeaders(), httpRequestHeaders), + authenticatedHeadersFor( + serverUrl = snapshot.serverUrl, + requestUrl = uri.toString(), + sessionHeaders = snapshot.asRequestHeaders(), + explicitHeaders = httpRequestHeaders, + ), ) .build() } @@ -225,6 +243,29 @@ internal fun mergeSessionAuthHeaders( } } +internal fun authenticatedHeadersFor( + serverUrl: String, + requestUrl: String, + sessionHeaders: Map, + explicitHeaders: Map, +): Map { + val resolvedRequestUrl = resolveRoutedDataSourceUrl(serverUrl, requestUrl) + val scopedSessionHeaders = if (isSameHttpOrigin(serverUrl, resolvedRequestUrl)) { + sessionHeaders + } else { + emptyMap() + } + return mergeSessionAuthHeaders(scopedSessionHeaders, explicitHeaders) +} + +internal fun shouldRefreshMediaRequest( + serverUrl: String, + requestUrl: String, + responseCode: Int, +): Boolean = + responseCode == 401 && + isSameHttpOrigin(serverUrl, resolveRoutedDataSourceUrl(serverUrl, requestUrl)) + /** * Picks between a [FileDataSource] (offline media playback) and the shared * [OkHttpDataSource] (every other scheme) based on the DataSpec's URI. Also @@ -310,6 +351,7 @@ internal fun resolveRoutedDataSourceUrl(serverUrl: String, rawUri: String): Stri trimmed.startsWith("https://", ignoreCase = true) || trimmed.startsWith("file://", ignoreCase = true) || trimmed.startsWith("content://", ignoreCase = true) -> trimmed + "://" in trimmed -> trimmed trimmed.startsWith("/") -> resolvePlaybackStreamUrl(serverUrl, trimmed) else -> "${serverUrl.trimEnd('/')}/${trimmed.trimStart('/')}" } @@ -318,6 +360,7 @@ internal fun resolveRoutedDataSourceUrl(serverUrl: String, rawUri: String): Stri @UnstableApi internal class SubripNormalizingDataSource( private val upstream: DataSource, + private val maxBytes: Long = MAX_SUBTITLE_BYTES, ) : DataSource { private var normalizedData: ByteArray? = null private var normalizedPosition: Int = 0 @@ -336,14 +379,28 @@ internal class SubripNormalizingDataSource( return upstream.open(dataSpec) } - upstream.open(dataSpec) + val declaredLength = upstream.open(dataSpec) uri = upstream.uri ?: dataSpec.uri val raw = try { + if (declaredLength >= 0) { + checkedLimitedByteCount( + currentBytes = 0, + additionalBytes = declaredLength, + maxBytes = maxBytes, + limitName = "subtitle", + ) + } readAllFromUpstream() } finally { upstream.close() } val normalized = normalizeSubripDataIfNeeded(raw) + checkedLimitedByteCount( + currentBytes = 0, + additionalBytes = normalized.size.toLong(), + maxBytes = maxBytes, + limitName = "normalized subtitle", + ) normalizedData = normalized return normalized.size.toLong() } @@ -372,10 +429,19 @@ internal class SubripNormalizingDataSource( private fun readAllFromUpstream(): ByteArray { val out = ByteArrayOutputStream() val buffer = ByteArray(DEFAULT_SUBRIP_READ_BUFFER_SIZE) + var total = 0L while (true) { val read = upstream.read(buffer, 0, buffer.size) if (read == C.RESULT_END_OF_INPUT) break - if (read > 0) out.write(buffer, 0, read) + if (read > 0) { + total = checkedLimitedByteCount( + currentBytes = total, + additionalBytes = read.toLong(), + maxBytes = maxBytes, + limitName = "subtitle", + ) + out.write(buffer, 0, read) + } } return out.toByteArray() } @@ -390,4 +456,5 @@ internal fun shouldNormalizeSubripPath(path: String?, position: Long): Boolean = internal fun normalizeSubripDataIfNeeded(raw: ByteArray): ByteArray = normalizeSubripPayloadIfNeeded(raw, 0, raw.size) ?: raw +internal const val MAX_SUBTITLE_BYTES = 32L * 1024 * 1024 private const val DEFAULT_SUBRIP_READ_BUFFER_SIZE = 16 * 1024 diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/MediaAuthInterceptor.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/MediaAuthInterceptor.kt index fd8ba1b82..da09df161 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/MediaAuthInterceptor.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/MediaAuthInterceptor.kt @@ -7,6 +7,7 @@ import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import org.siloserver.silo.network.isSameHttpOrigin /** * OkHttp interceptor that mirrors [org.siloserver.silo.network.SiloAuthPlugin] @@ -36,6 +37,9 @@ class MediaAuthInterceptor( override fun intercept(chain: Interceptor.Chain): Response { val original = chain.request() val failedSnapshot = runBlocking { authSession.snapshot() } + if (!isSameHttpOrigin(failedSnapshot.serverUrl, original.url.toString())) { + return chain.proceed(original.withoutSiloCredentials()) + } val authed = original.newBuilder() .applyAuthHeaders(failedSnapshot) @@ -55,9 +59,14 @@ class MediaAuthInterceptor( return chain.proceed(authed) } - val retried = original.newBuilder() - .applyAuthHeaders(runBlocking { authSession.snapshot() }) - .build() + val retrySnapshot = runBlocking { authSession.snapshot() } + val retried = if (isSameHttpOrigin(retrySnapshot.serverUrl, original.url.toString())) { + original.newBuilder() + .applyAuthHeaders(retrySnapshot) + .build() + } else { + original.withoutSiloCredentials() + } return chain.proceed(retried) } @@ -66,3 +75,15 @@ class MediaAuthInterceptor( return this } } + +private fun Request.withoutSiloCredentials(): Request = + newBuilder() + .removeHeader("Authorization") + .removeHeader("X-Profile-Id") + .removeHeader("X-Profile-Token") + .apply { + headers.names() + .filter { name -> name.startsWith("X-Silo-", ignoreCase = true) } + .forEach(::removeHeader) + } + .build() diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/MediaAuthSession.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/MediaAuthSession.kt index 4f4bfd570..d2c02f622 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/MediaAuthSession.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/MediaAuthSession.kt @@ -10,7 +10,11 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.siloserver.silo.model.auth.RefreshRequest import org.siloserver.silo.model.auth.RefreshResponse +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.CleartextOriginConsent import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.isSameHttpOrigin +import org.siloserver.silo.network.requiresApproval import java.io.IOException /** @@ -25,17 +29,87 @@ class MediaAuthSession( private val tokenManager: TokenManager, private val refreshClient: OkHttpClient, private val json: Json = Json { ignoreUnknownKeys = true }, + private val cleartextOriginConsent: CleartextOriginConsent? = null, ) { private val refreshMutex = Mutex() - suspend fun snapshot(): MediaAuthSnapshot = MediaAuthSnapshot( - accessToken = tokenManager.getAccessToken(), - profileId = tokenManager.getProfileId(), - profileToken = tokenManager.getProfileToken(), - serverId = tokenManager.getCurrentServerId(), - ) + suspend fun isTransportApproved(requestUrl: String): Boolean = + cleartextOriginConsent?.requiresApproval(requestUrl) != true + + suspend fun snapshot(): MediaAuthSnapshot { + tokenManager.snapshotCurrentScope()?.let { scope -> + if (cleartextOriginConsent?.requiresApproval(scope.serverUrl) == true) { + return MediaAuthSnapshot(null, null, null, scope.serverId, "", null) + } + val accessToken = tokenManager.getAccessTokenForScope(scope) + val scopeAfter = tokenManager.snapshotCurrentScope() + if (scopeAfter != scope || accessToken.isNullOrBlank()) { + return MediaAuthSnapshot( + accessToken = null, + profileId = null, + profileToken = null, + serverId = scopeAfter?.serverId, + serverUrl = "", + authScope = null, + ) + } + return MediaAuthSnapshot( + accessToken = accessToken, + profileId = scope.profileId, + profileToken = scope.profileToken, + serverId = scope.serverId, + serverUrl = scope.serverUrl, + authScope = scope, + ) + } + + val serverIdBefore = tokenManager.getCurrentServerId() + val serverUrl = tokenManager.getServerUrl() + if (cleartextOriginConsent?.requiresApproval(serverUrl) == true) { + return MediaAuthSnapshot(null, null, null, tokenManager.getCurrentServerId(), "") + } + val accessToken = tokenManager.getAccessToken() + val profileId = tokenManager.getProfileId() + val profileToken = tokenManager.getProfileToken() + val serverIdAfter = tokenManager.getCurrentServerId() + val serverUrlAfter = tokenManager.getServerUrl() + + // TokenManager exposes the active fields separately. If the user + // switches servers while they are read, never combine one server's + // credentials with another server's URL. An empty URL makes every + // transport's origin check fail closed and also suppresses refresh. + if ( + serverIdBefore != serverIdAfter || + !isSameHttpOrigin(serverUrl, serverUrlAfter) + ) { + return MediaAuthSnapshot( + accessToken = null, + profileId = null, + profileToken = null, + serverId = serverIdAfter, + serverUrl = "", + ) + } + return MediaAuthSnapshot( + accessToken = accessToken, + profileId = profileId, + profileToken = profileToken, + serverId = serverIdAfter, + serverUrl = serverUrlAfter, + ) + } suspend fun refreshIfStale(failedSnapshot: MediaAuthSnapshot): Boolean = refreshMutex.withLock { + failedSnapshot.authScope?.let { failedScope -> + val currentScope = tokenManager.snapshotCurrentScope() + if (currentScope != failedScope) return@withLock false + val currentAccessToken = tokenManager.getAccessTokenForScope(failedScope) + if (!currentAccessToken.isNullOrBlank() && currentAccessToken != failedSnapshot.accessToken) { + return@withLock true + } + return@withLock attemptRefresh(failedScope) + } + val current = snapshot() if (current.serverId != failedSnapshot.serverId) { return@withLock false @@ -46,6 +120,42 @@ class MediaAuthSession( attemptRefresh(failedSnapshot.serverId) } + private suspend fun attemptRefresh(scope: AuthScopeSnapshot): Boolean { + val refreshToken = tokenManager.getRefreshTokenForScope(scope) ?: return false + if (refreshToken.isBlank() || scope.serverUrl.isBlank()) return false + if (tokenManager.snapshotCurrentScope() != scope) return false + + val request = refreshRequest(scope.serverUrl, refreshToken) + return try { + refreshClient.newCall(request).execute().use { response -> + if (tokenManager.snapshotCurrentScope() != scope) return@use false + if (tokenManager.getRefreshTokenForScope(scope).isNullOrBlank()) return@use false + if (!response.isSuccessful) { + // A temporary remote-playback overlay is process-only. A + // rejected refresh must not remove it and expose the saved + // owner's credentials to the still-running media request. + if ( + response.code.shouldInvalidateSessionAfterMediaRefreshFailure() && + scope.credentialGenerationId == null + ) { + tokenManager.invalidateSessionForScope(scope) + } + return@use false + } + val tokens = decodeRefresh(response.body?.string().orEmpty()) ?: return@use false + tokenManager.saveTokensForScope( + scope = scope, + accessToken = tokens.accessToken, + refreshToken = tokens.refreshToken, + expiresIn = tokens.expiresIn, + ) + tokenManager.getAccessTokenForScope(scope) == tokens.accessToken + } + } catch (_: IOException) { + false + } + } + private suspend fun attemptRefresh(serverIdBeforeRequest: String?): Boolean { val refreshToken = tokenManager.getRefreshToken() ?: return false val serverUrl = tokenManager.getServerUrl() @@ -56,13 +166,7 @@ class MediaAuthSession( // reject it. if (tokenManager.getCurrentServerId() != serverIdBeforeRequest) return false - val request = Request.Builder() - .url(serverUrl.trimEnd('/') + "/api/v1/auth/refresh") - .post( - json.encodeToString(RefreshRequest(refreshToken)) - .toRequestBody("application/json; charset=utf-8".toMediaType()), - ) - .build() + val request = refreshRequest(serverUrl, refreshToken) return try { refreshClient.newCall(request).execute().use { response -> @@ -80,9 +184,7 @@ class MediaAuthSession( } return@use false } - val tokens = runCatching { - json.decodeFromString(response.body?.string().orEmpty()) - }.getOrNull() ?: return@use false + val tokens = decodeRefresh(response.body?.string().orEmpty()) ?: return@use false tokenManager.saveTokens( accessToken = tokens.accessToken, refreshToken = tokens.refreshToken, @@ -94,6 +196,18 @@ class MediaAuthSession( false } } + + private fun refreshRequest(serverUrl: String, refreshToken: String): Request = + Request.Builder() + .url(serverUrl.trimEnd('/') + "/api/v1/auth/refresh") + .post( + json.encodeToString(RefreshRequest(refreshToken)) + .toRequestBody("application/json; charset=utf-8".toMediaType()), + ) + .build() + + private fun decodeRefresh(body: String): RefreshResponse? = + runCatching { json.decodeFromString(body) }.getOrNull() } data class MediaAuthSnapshot( @@ -101,6 +215,8 @@ data class MediaAuthSnapshot( val profileId: String?, val profileToken: String?, val serverId: String?, + val serverUrl: String, + internal val authScope: AuthScopeSnapshot? = null, ) { fun asRequestHeaders(): Map = buildMap { accessToken?.takeIf { it.isNotBlank() }?.let { put("Authorization", "Bearer $it") } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlayerOkHttpClient.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlayerOkHttpClient.kt index 3d7634df5..56338fa9a 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlayerOkHttpClient.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlayerOkHttpClient.kt @@ -4,6 +4,14 @@ import okhttp3.ConnectionPool import okhttp3.Dispatcher import okhttp3.OkHttpClient import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Interceptor +import okhttp3.Response +import kotlinx.coroutines.runBlocking +import org.siloserver.silo.network.CleartextOriginConsent +import org.siloserver.silo.network.CleartextOriginNotApprovedException +import org.siloserver.silo.network.isSameHttpOrigin +import org.siloserver.silo.network.requiresApproval import java.util.concurrent.TimeUnit /** @@ -15,18 +23,59 @@ import java.util.concurrent.TimeUnit * chain on the Ktor client must not see media traffic. Media3 auth lives above * this transport in [RefreshingHttpDataSource]. */ -internal fun buildPlayerOkHttpClient(): OkHttpClient = +internal fun buildPlayerOkHttpClient( + cleartextOriginConsent: CleartextOriginConsent? = null, +): OkHttpClient = OkHttpClient.Builder() .connectTimeout(20, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .followRedirects(true) .followSslRedirects(true) + // Network interceptors run once for the initial request and again for + // every redirect follow-up. This is the last boundary before bytes + // leave the process, so arbitrary plan headers and query credentials + // cannot ride an HTTPS→HTTP or approved→unapproved redirect. + .addNetworkInterceptor(CleartextConsentNetworkInterceptor(cleartextOriginConsent)) + .addNetworkInterceptor { chain -> + val initialTarget = chain.call().request().url.toString() + val networkRequest = chain.request() + val safeRequest = if (isSameHttpOrigin(initialTarget, networkRequest.url.toString())) { + networkRequest + } else { + networkRequest.withoutCrossOriginCredentials() + } + chain.proceed(safeRequest) + } .protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1)) .connectionPool(ConnectionPool(maxIdleConnections = 5, keepAliveDuration = 5, timeUnit = TimeUnit.MINUTES)) .dispatcher(Dispatcher()) .build() +internal class CleartextConsentNetworkInterceptor( + private val consent: CleartextOriginConsent?, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val url = chain.request().url.toString() + if (runBlocking { consent?.requiresApproval(url) == true }) { + throw CleartextOriginNotApprovedException(url) + } + return chain.proceed(chain.request()) + } +} + +private fun Request.withoutCrossOriginCredentials(): Request = + newBuilder() + .removeHeader("Authorization") + .removeHeader("X-Profile-Id") + .removeHeader("X-Profile-Token") + .apply { + headers.names() + .filter { name -> name.startsWith("X-Silo-", ignoreCase = true) } + .forEach(::removeHeader) + } + .build() + /** * Bounded bootstrap client for the token-refresh RPC. It intentionally has no * auth interceptor so a rejected refresh cannot recurse through the media diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/io/LimitedStreamsTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/io/LimitedStreamsTest.kt new file mode 100644 index 000000000..453f4a6c2 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/io/LimitedStreamsTest.kt @@ -0,0 +1,52 @@ +package org.siloserver.silo.common.io + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class LimitedStreamsTest { + @Test + fun `copy accepts exactly the byte limit`() { + val output = ByteArrayOutputStream() + + val copied = ByteArrayInputStream(byteArrayOf(1, 2, 3, 4)) + .copyToLimited(output, maxBytes = 4) + + assertEquals(4, copied) + assertContentEquals(byteArrayOf(1, 2, 3, 4), output.toByteArray()) + } + + @Test + fun `copy rejects limit plus one without writing the overflowing chunk`() { + val output = ByteArrayOutputStream() + + val error = assertFailsWith { + ByteArrayInputStream(byteArrayOf(1, 2, 3, 4, 5)) + .copyToLimited(output, maxBytes = 4, bufferSize = 2) + } + + assertEquals(4, error.maxBytes) + assertContentEquals(byteArrayOf(1, 2, 3, 4), output.toByteArray()) + } + + @Test + fun `checked byte count rejects overflow instead of wrapping`() { + assertFailsWith { + checkedLimitedByteCount( + currentBytes = Long.MAX_VALUE - 1, + additionalBytes = 2, + maxBytes = Long.MAX_VALUE, + ) + } + } + + @Test + fun `negative limits are rejected`() { + assertFailsWith { + ByteArrayInputStream(byteArrayOf()).copyToLimited(ByteArrayOutputStream(), -1) + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/CleartextConsentStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/CleartextConsentStoreTest.kt new file mode 100644 index 000000000..04316f85a --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/CleartextConsentStoreTest.kt @@ -0,0 +1,59 @@ +package org.siloserver.silo.common.network + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import java.nio.file.Files +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CleartextConsentStoreTest { + + @Test + fun approvalPersistsOnlyNormalizedOriginDigest() = runTest { + val file = Files.createTempDirectory("cleartext-consent").resolve("preferences.preferences_pb").toFile() + val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } + val store = DataStoreCleartextConsentStore(dataStore) + val supplied = "HTTP://User:secret@SILO.LAN:80/private?token=credential#fragment" + + store.approve(supplied) + + assertTrue(store.isApproved("http://silo.lan/another-path")) + val persisted = dataStore.data.first().asMap() + assertEquals(1, persisted.size) + val digests = persisted.values.single() as Set<*> + assertEquals(1, digests.size) + assertTrue((digests.single() as String).matches(Regex("[0-9a-f]{64}"))) + val serialized = persisted.toString() + assertFalse(serialized.contains("silo.lan", ignoreCase = true)) + assertFalse(serialized.contains("secret", ignoreCase = true)) + assertFalse(serialized.contains("token", ignoreCase = true)) + assertFalse(serialized.contains("credential", ignoreCase = true)) + } + + @Test + fun approvalsAreSpecificToNormalizedSchemeHostAndPort() = runTest { + val file = Files.createTempDirectory("cleartext-origin").resolve("preferences.preferences_pb").toFile() + val store = DataStoreCleartextConsentStore( + PreferenceDataStoreFactory.create(scope = backgroundScope) { file }, + ) + + store.approve("http://SILO.LAN:8090/path") + + assertTrue(store.isApproved("http://silo.lan:8090/other")) + assertFalse(store.isApproved("http://silo.lan:8091")) + assertFalse(store.isApproved("http://other.lan:8090")) + assertFalse(store.isApproved("https://silo.lan:8090")) + } + + @Test + fun normalizedOriginDropsCredentialsPathQueryFragmentAndDefaultPort() { + assertEquals( + "http://silo.lan", + cleartextOrigin("HTTP://user:password@SILO.LAN:80/path?q=token#fragment"), + ) + assertEquals("http://silo.lan:8090", cleartextOrigin("http://SILO.LAN:8090/path")) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/pairing/RegistryPairingAuthPortTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/pairing/RegistryPairingAuthPortTest.kt index dd78e9551..b8d7f3893 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/pairing/RegistryPairingAuthPortTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/pairing/RegistryPairingAuthPortTest.kt @@ -10,9 +10,37 @@ import org.siloserver.silo.network.EncryptedTokenManagerImpl import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertFailsWith +import org.siloserver.silo.network.CleartextOriginConsent +import org.siloserver.silo.network.CleartextOriginNotApprovedException @RunWith(RobolectricTestRunner::class) class RegistryPairingAuthPortTest { + @Test + fun unapprovedCleartextPairingCannotPersistCredentials() = runTest { + val context = ApplicationProvider.getApplicationContext() + val prefs = context.getSharedPreferences("pairing-cleartext-test", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + val registry = AndroidServerRegistry(prefs) + val tokens = EncryptedTokenManagerImpl(prefs, registry) + val consent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + } + + assertFailsWith { + RegistryPairingAuthPort(tokens, registry, consent).persistApprovedSession( + serverUrl = "http://silo.lan", + serverName = "Unsafe", + accessToken = "access", + refreshToken = "refresh", + expiresIn = 3600, + ) + } + + assertNull(registry.activeEntry.value) + assertNull(tokens.getAccessToken()) + } + @Test fun approvedSameUrlSessionClearsOldProfileAndReplacesTokens() = runTest { val context = ApplicationProvider.getApplicationContext() diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/AuthenticatedDataSourceFactoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/AuthenticatedDataSourceFactoryTest.kt index 1edd566d1..9154c160a 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/AuthenticatedDataSourceFactoryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/AuthenticatedDataSourceFactoryTest.kt @@ -19,6 +19,10 @@ import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.CleartextOriginConsent +import org.siloserver.silo.network.CleartextOriginNotApprovedException +import org.siloserver.silo.common.io.ContentLimitExceeded +import androidx.media3.datasource.DataSource @RunWith(RobolectricTestRunner::class) class AuthenticatedDataSourceFactoryTest { @@ -56,9 +60,43 @@ class AuthenticatedDataSourceFactoryTest { ) } + @Test + fun `unapproved cleartext stream is rejected before explicit plan headers reach transport`() { + val transport = FakeHttpDataSource() + val client = OkHttpClient().also { closeables += it } + val tokens = TokenManagerImpl() + runBlocking { tokens.setServerUrl("https://silo.example") } + val source = RefreshingHttpDataSource( + factory = FakeHttpDataSourceFactory(ArrayDeque(listOf(transport))), + authSession = MediaAuthSession( + tokenManager = tokens, + refreshClient = client, + cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + }, + ), + ) + val plan = DataSpec.Builder() + .setUri(Uri.parse("http://cdn.example/video")) + .setHttpRequestHeaders( + mapOf( + "Authorization" to "Bearer stream-plan", + "X-Stream-Signature" to "secret", + ), + ) + .build() + + assertFailsWith { + source.open(plan) + } + assertEquals(emptyList(), transport.openedDataSpecs) + } + @Test fun explicitPlanHeadersOverrideSessionAuthCaseInsensitively() { - val merged = mergeSessionAuthHeaders( + val merged = authenticatedHeadersFor( + serverUrl = "https://silo.example", + requestUrl = "https://silo.example/video", sessionHeaders = mapOf( "Authorization" to "Bearer silo-session", "X-Profile-Id" to "profile-1", @@ -75,6 +113,101 @@ class AuthenticatedDataSourceFactoryTest { assertEquals("route-7", merged["X-Stream-Scope"]) } + @Test + fun foreignOriginReceivesOnlyExplicitTargetHeaders() { + assertEquals( + mapOf("X-Stream-Scope" to "route-7"), + authenticatedHeadersFor( + serverUrl = "https://silo.example", + requestUrl = "https://cdn.example/video", + sessionHeaders = mapOf( + "Authorization" to "Bearer silo-session", + "X-Profile-Id" to "profile-1", + "X-Profile-Token" to "profile-token", + ), + explicitHeaders = mapOf("X-Stream-Scope" to "route-7"), + ), + ) + } + + @Test + fun explicitAuthorizationIsPreservedOnlyForItsIssuedTarget() { + assertEquals( + mapOf("Authorization" to "Signed cdn-route-7"), + authenticatedHeadersFor( + serverUrl = "https://silo.example", + requestUrl = "https://cdn.example/video", + sessionHeaders = mapOf("Authorization" to "Bearer silo-session"), + explicitHeaders = mapOf("Authorization" to "Signed cdn-route-7"), + ), + ) + } + + @Test + fun sessionHeadersRequireExactSchemeHostAndPort() { + val sessionHeaders = mapOf( + "Authorization" to "Bearer silo-session", + "X-Profile-Id" to "profile-1", + "X-Profile-Token" to "profile-token", + ) + listOf( + "https://cdn.silo.example/video", + "https://silo.example:444/video", + "http://silo.example/video", + "file:///tmp/video", + "://malformed", + ).forEach { requestUrl -> + assertEquals( + emptyMap(), + authenticatedHeadersFor( + serverUrl = "https://silo.example", + requestUrl = requestUrl, + sessionHeaders = sessionHeaders, + explicitHeaders = emptyMap(), + ), + "Session credentials leaked to $requestUrl", + ) + } + } + + @Test + fun relativeMediaAndSubtitleUrlsResolveBeforeOriginPolicy() { + val sessionHeaders = mapOf("Authorization" to "Bearer silo-session") + + listOf( + "/api/v1/stream/session-1", + "api/v1/stream/session-1/subtitles/4.srt", + ).forEach { requestUrl -> + assertEquals( + sessionHeaders, + authenticatedHeadersFor( + serverUrl = "https://silo.example", + requestUrl = requestUrl, + sessionHeaders = sessionHeaders, + explicitHeaders = emptyMap(), + ), + ) + } + } + + @Test + fun foreignUnauthorizedMediaResponseDoesNotRefresh() { + assertFalse( + shouldRefreshMediaRequest( + serverUrl = "https://silo.example", + requestUrl = "https://cdn.example/video", + responseCode = 401, + ), + ) + assertTrue( + shouldRefreshMediaRequest( + serverUrl = "https://silo.example", + requestUrl = "https://silo.example/video", + responseCode = 401, + ), + ) + } + @Test fun srtSubtitlePayloadBytesAreNormalizedBeforeMedia3ParsesThem() { val loose = """ @@ -352,4 +485,73 @@ class AuthenticatedDataSourceFactoryTest { closed = true } } + + @Test + fun subripNormalizationAcceptsExactlyTheStreamLimit() { + val upstream = ByteArrayDataSource(byteArrayOf(1, 2, 3, 4)) + val source = SubripNormalizingDataSource(upstream, maxBytes = 4) + + assertEquals(4, source.open(subripDataSpec())) + assertTrue(upstream.closed) + } + + @Test + fun subripNormalizationRejectsDeclaredLimitPlusOneBeforeReading() { + val upstream = ByteArrayDataSource( + bytes = byteArrayOf(1), + declaredLength = 5, + ) + val source = SubripNormalizingDataSource(upstream, maxBytes = 4) + + assertFailsWith { + source.open(subripDataSpec()) + } + assertEquals(0, upstream.readCalls) + assertTrue(upstream.closed) + } + + @Test + fun subripNormalizationRejectsStreamedLimitPlusOneAndClosesUpstream() { + val upstream = ByteArrayDataSource( + bytes = byteArrayOf(1, 2, 3, 4, 5), + declaredLength = C.LENGTH_UNSET.toLong(), + ) + val source = SubripNormalizingDataSource(upstream, maxBytes = 4) + + assertFailsWith { + source.open(subripDataSpec()) + } + assertTrue(upstream.closed) + } + + private fun subripDataSpec(): DataSpec = + DataSpec(Uri.parse("https://silo.example/subtitles/1.srt")) + + private class ByteArrayDataSource( + private val bytes: ByteArray, + private val declaredLength: Long = bytes.size.toLong(), + ) : DataSource { + private var position = 0 + var readCalls = 0 + var closed = false + + override fun addTransferListener(transferListener: TransferListener) = Unit + + override fun open(dataSpec: DataSpec): Long = declaredLength + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + readCalls += 1 + if (position == bytes.size) return C.RESULT_END_OF_INPUT + val count = minOf(length, bytes.size - position) + bytes.copyInto(buffer, offset, position, position + count) + position += count + return count + } + + override fun getUri(): Uri = Uri.parse("https://silo.example/subtitles/1.srt") + + override fun close() { + closed = true + } + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/MediaAuthInterceptorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/MediaAuthInterceptorTest.kt index 3a2f0a7ee..471710baa 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/MediaAuthInterceptorTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/MediaAuthInterceptorTest.kt @@ -2,6 +2,9 @@ package org.siloserver.silo.common.player import org.siloserver.silo.network.TokenManager import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.CleartextOriginConsent +import org.siloserver.silo.network.CleartextOriginNotApprovedException +import org.siloserver.silo.network.canonicalHttpOrigin import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -16,19 +19,57 @@ import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.test.assertFailsWith class MediaAuthInterceptorTest { + @Test + fun `redirect from approved origin to unapproved cleartext is blocked before downstream`() { + val origin = MockWebServer() + val downstream = MockWebServer() + origin.start() + downstream.start() + try { + origin.enqueue( + MockResponse() + .setResponseCode(302) + .setHeader("Location", downstream.url("/asset")), + ) + val approvedOrigin = canonicalHttpOrigin(origin.url("/").toString()) + val consent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = + canonicalHttpOrigin(origin) == approvedOrigin + } + val client = buildPlayerOkHttpClient(consent) + val request = Request.Builder() + .url(origin.url("/redirect")) + .header("X-Stream-Signature", "secret-plan-header") + .build() + + assertFailsWith { + client.newCall(request).execute().close() + } + assertEquals(1, origin.requestCount) + assertEquals(0, downstream.requestCount) + } finally { + origin.shutdown() + downstream.shutdown() + } + } + @Test fun `adds auth and active profile headers to media and reader requests`() { val tokenManager = TokenManagerImpl() runBlocking { + tokenManager.setServerUrl("https://lib.strm.cafe") tokenManager.saveTokens("access-token", "refresh-token", expiresIn = 3600) tokenManager.setProfileId("profile-1") tokenManager.setProfileToken("profile-token") @@ -53,6 +94,194 @@ class MediaAuthInterceptorTest { } } + @Test + fun `foreign origins receive no auth or profile headers`() { + val tokenManager = TokenManagerImpl() + runBlocking { + tokenManager.setServerUrl("https://lib.strm.cafe") + tokenManager.saveTokens("access-token", "refresh-token", expiresIn = 3600) + tokenManager.setProfileId("profile-1") + tokenManager.setProfileToken("profile-token") + } + + listOf( + "https://cdn.lib.strm.cafe/video", + "https://lib.strm.cafe:444/video", + "http://lib.strm.cafe/video", + ).forEach { url -> + val chain = CapturingChain( + Request.Builder() + .url(url) + .header("Authorization", "Signed explicit-credential") + .header("X-Profile-Id", "explicit-profile") + .header("X-Profile-Token", "explicit-profile-token") + .build(), + ) + + MediaAuthInterceptor(tokenManager, refreshClient = OkHttpClient()).intercept(chain) + + val request = chain.capturedRequest ?: error("request was not captured") + assertNull(request.header("Authorization"), "Authorization leaked to $url") + assertNull(request.header("X-Profile-Id"), "X-Profile-Id leaked to $url") + assertNull(request.header("X-Profile-Token"), "X-Profile-Token leaked to $url") + } + } + + @Test + fun `foreign unauthorized response causes no refresh attempt`() { + val tokenManager = FakeTokenManager( + accessToken = "expired-access", + refreshToken = "refresh-token", + serverUrl = "https://lib.strm.cafe", + serverId = "server-a", + ) + var refreshRequests = 0 + val refreshClient = OkHttpClient.Builder() + .addInterceptor { chain -> + refreshRequests += 1 + responseFor(chain.request(), code = 500) + } + .build() + val chain = SequenceChain( + Request.Builder() + .url("https://cdn.example/video") + .build(), + responseCodes = listOf(401, 401), + ) + + MediaAuthInterceptor(tokenManager, refreshClient = refreshClient).intercept(chain).close() + + assertEquals(0, refreshRequests) + assertEquals(1, chain.proceedCalls) + assertFalse(tokenManager.invalidatedSession) + } + + @Test + fun `server switch while snapshot is read cannot pair old credentials with new origin`() { + val tokenManager = FakeTokenManager( + accessToken = "server-a-access", + refreshToken = "server-a-refresh", + serverUrl = "https://server-a.example", + serverId = "server-a", + ).apply { + runBlocking { + setProfileId("server-a-profile") + setProfileToken("server-a-profile-token") + } + onGetServerUrl = { + serverId = "server-b" + serverUrl = "https://server-b.example" + } + } + val chain = CapturingChain( + Request.Builder() + .url("https://server-b.example/video") + .build(), + ) + + MediaAuthInterceptor(tokenManager, refreshClient = OkHttpClient()).intercept(chain) + + val request = chain.capturedRequest ?: error("request was not captured") + assertNull(request.header("Authorization")) + assertNull(request.header("X-Profile-Id")) + assertNull(request.header("X-Profile-Token")) + } + + @Test + fun `cross origin redirect strips silo and explicit authorization headers`() { + val origin = MockWebServer() + val downstream = MockWebServer() + origin.start() + downstream.start() + try { + origin.enqueue( + MockResponse() + .setResponseCode(302) + .setHeader("Location", downstream.url("/asset")), + ) + downstream.enqueue(MockResponse().setResponseCode(200).setBody("ok")) + val tokenManager = FakeTokenManager( + accessToken = null, + refreshToken = "refresh-token", + serverUrl = origin.url("/").toString(), + serverId = "server-a", + ).apply { + runBlocking { + setProfileId("profile-1") + setProfileToken("profile-token") + } + } + val client = buildPlayerOkHttpClient() + .newBuilder() + .addInterceptor(MediaAuthInterceptor(tokenManager, refreshClient = OkHttpClient())) + .build() + val request = Request.Builder() + .url(origin.url("/redirect")) + .header("Authorization", "Signed explicit-target-credential") + .header("X-Silo-Device-Id", "device-1") + .build() + + client.newCall(request).execute().use { response -> + assertEquals(200, response.code) + } + + val received = downstream.takeRequest() + assertNull(received.getHeader("Authorization")) + assertNull(received.getHeader("X-Profile-Id")) + assertNull(received.getHeader("X-Profile-Token")) + assertNull(received.getHeader("X-Silo-Device-Id")) + } finally { + origin.shutdown() + downstream.shutdown() + } + } + + @Test + fun `retry never applies credentials from a newly active foreign server`() { + val tokenManager = FakeTokenManager( + accessToken = "server-a-expired", + refreshToken = "server-a-refresh", + serverUrl = "https://server-a.example", + serverId = "server-a", + ) + val refreshClient = OkHttpClient.Builder() + .addInterceptor { chain -> + responseFor( + chain.request(), + code = 200, + body = """ + { + "access_token": "server-a-fresh", + "refresh_token": "server-a-fresh-refresh", + "expires_in": 3600 + } + """.trimIndent(), + ) + } + .build() + tokenManager.onSaveTokens = { + tokenManager.switchScope( + accessToken = "server-b-access", + refreshToken = "server-b-refresh", + serverUrl = "https://server-b.example", + serverId = "server-b", + ) + } + val chain = SequenceChain( + Request.Builder() + .url("https://server-a.example/video") + .build(), + responseCodes = listOf(401, 401), + ) + + MediaAuthInterceptor(tokenManager, refreshClient = refreshClient).intercept(chain).close() + + val retry = chain.capturedRequests.last() + assertNull(retry.header("Authorization")) + assertNull(retry.header("X-Profile-Id")) + assertNull(retry.header("X-Profile-Token")) + } + @Test fun `failed refresh invalidates session`() { val tokenManager = FakeTokenManager( @@ -276,10 +505,13 @@ class MediaAuthInterceptorTest { private val responseCodes: List, ) : Interceptor.Chain { private var calls = 0 + val proceedCalls: Int get() = calls + val capturedRequests = mutableListOf() override fun request(): Request = request override fun proceed(request: Request): Response { + capturedRequests += request val code = responseCodes.getOrElse(calls) { responseCodes.last() } calls += 1 return mediaAuthTestResponseFor(request, code) @@ -302,6 +534,7 @@ class MediaAuthInterceptorTest { var serverId: String?, ) : TokenManager { var onGetServerUrl: (() -> Unit)? = null + var onSaveTokens: (() -> Unit)? = null var invalidatedSession = false private set var savedTokens = false @@ -320,6 +553,7 @@ class MediaAuthInterceptorTest { savedTokens = true this.accessToken = accessToken this.refreshToken = refreshToken + onSaveTokens?.invoke() } override suspend fun clearTokens() { @@ -364,6 +598,18 @@ class MediaAuthInterceptorTest { override suspend fun signOutCurrentServer() { clearTokens() } + + fun switchScope( + accessToken: String?, + refreshToken: String?, + serverUrl: String, + serverId: String?, + ) { + this.accessToken = accessToken + this.refreshToken = refreshToken + this.serverUrl = serverUrl + this.serverId = serverId + } } private fun responseFor( diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/MediaAuthSessionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/MediaAuthSessionTest.kt index e5db41c6e..20e13539a 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/MediaAuthSessionTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/MediaAuthSessionTest.kt @@ -1,13 +1,19 @@ package org.siloserver.silo.common.player import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.CleartextOriginConsent +import org.siloserver.silo.network.TokenManager import org.siloserver.silo.network.TokenManagerImpl import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class MediaAuthSessionTest { @@ -44,4 +50,156 @@ class MediaAuthSessionTest { assertEquals("profile-1", fresh["X-Profile-Id"]) } } + + @Test + fun temporaryPlaybackRefreshRejectionNeverFallsThroughToSavedOwner() { + val owner = AuthScopeSnapshot("server-a", "owner", "https://silo.example", null, credentialEpoch = 1) + val guest = AuthScopeSnapshot( + "server-a", + "guest", + "https://silo.example", + null, + credentialGenerationId = "guest-generation", + ) + val tokens = ScopedTokenManager(owner, "owner-access", "owner-refresh") + tokens.install(guest, "guest-access", "guest-refresh") + tokens.current = guest + val refreshClient = respondingClient(401) + val session = MediaAuthSession(tokens, refreshClient) + + runBlocking { + val failed = session.snapshot() + assertFalse(session.refreshIfStale(failed)) + } + + assertEquals(guest, tokens.current) + assertEquals("guest-access", tokens.access(guest)) + assertEquals("owner-access", tokens.access(owner)) + assertEquals(emptyList(), tokens.invalidatedScopes) + } + + @Test + fun sameServerReloginCannotRefreshUsingStalePlaybackSnapshot() { + val oldLogin = AuthScopeSnapshot("server-a", "profile", "https://silo.example", null, credentialEpoch = 1) + val newLogin = oldLogin.copy(credentialEpoch = 3) + val tokens = ScopedTokenManager(oldLogin, "old-access", "old-refresh") + val refreshCalls = mutableListOf() + val session = MediaAuthSession( + tokens, + OkHttpClient.Builder().addInterceptor { chain -> + refreshCalls += chain.request().url.toString() + respondingClientResponse(chain.request(), 200) + }.build(), + ) + val failed = runBlocking { session.snapshot() } + tokens.install(newLogin, "new-access", "new-refresh") + tokens.current = newLogin + + assertFalse(runBlocking { session.refreshIfStale(failed) }) + assertEquals(emptyList(), refreshCalls) + assertEquals("new-access", tokens.access(newLogin)) + } + + @Test + fun unapprovedCleartextMediaSnapshotContainsNoCredentialsOrTrustedOrigin() { + val scope = AuthScopeSnapshot("server-a", "profile", "http://silo.lan", "profile-token", credentialEpoch = 1) + val tokens = ScopedTokenManager(scope, "access", "refresh") + val session = MediaAuthSession( + tokens, + respondingClient(200), + cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + }, + ) + + val snapshot = runBlocking { session.snapshot() } + + assertEquals(emptyMap(), snapshot.asRequestHeaders()) + assertEquals("", snapshot.serverUrl) + } + + private fun respondingClient(code: Int): OkHttpClient = + OkHttpClient.Builder().addInterceptor { chain -> + respondingClientResponse(chain.request(), code) + }.build() + + private fun respondingClientResponse(request: okhttp3.Request, code: Int): Response = + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message(if (code in 200..299) "OK" else "Rejected") + .body( + if (code in 200..299) { + """{"access_token":"fresh-access","refresh_token":"fresh-refresh","expires_in":3600}""" + .toResponseBody(null) + } else { + "".toResponseBody(null) + }, + ) + .build() + + private class ScopedTokenManager( + currentScope: AuthScopeSnapshot, + accessToken: String, + refreshToken: String, + ) : TokenManager { + private data class Tokens(var access: String, var refresh: String) + private val tokens = mutableMapOf() + var current: AuthScopeSnapshot = currentScope + val invalidatedScopes = mutableListOf() + private val expired = MutableSharedFlow() + override val sessionExpired: SharedFlow = expired + + init { + install(currentScope, accessToken, refreshToken) + } + + fun install(scope: AuthScopeSnapshot, access: String, refresh: String) { + tokens[scope] = Tokens(access, refresh) + } + + fun access(scope: AuthScopeSnapshot): String? = tokens[scope]?.access + + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = current + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = tokens[scope]?.access + override suspend fun getRefreshTokenForScope(scope: AuthScopeSnapshot): String? = tokens[scope]?.refresh + override suspend fun saveTokensForScope( + scope: AuthScopeSnapshot, + accessToken: String, + refreshToken: String, + expiresIn: Long, + ) { + tokens[scope] = Tokens(accessToken, refreshToken) + } + override suspend fun invalidateSessionForScope(scope: AuthScopeSnapshot): Boolean { + invalidatedScopes += scope + if (scope.credentialGenerationId == null) tokens.remove(scope) + return true + } + override suspend fun getAccessToken(): String? = tokens[current]?.access + override suspend fun getRefreshToken(): String? = tokens[current]?.refresh + override suspend fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long) { + tokens[current] = Tokens(accessToken, refreshToken) + } + override suspend fun clearTokens() { + tokens.remove(current) + } + override suspend fun invalidateSession() { + tokens.remove(current) + } + override suspend fun getProfileId(): String? = current.profileId + override suspend fun setProfileId(profileId: String?) = Unit + override suspend fun getProfileToken(): String? = current.profileToken + override suspend fun setProfileToken(token: String?) = Unit + override suspend fun getServerUrl(): String = current.serverUrl + override suspend fun setServerUrl(url: String) = Unit + override suspend fun getCurrentServerId(): String = current.serverId + override suspend fun switchActiveServer(serverId: String?) = Unit + override suspend fun signOutCurrentServer() = Unit + override suspend fun getAccessTokenForScope(serverId: String): String? = + tokens.entries.firstOrNull { it.key.serverId == serverId }?.value?.access + override suspend fun getRefreshTokenForScope(serverId: String): String? = + tokens.entries.firstOrNull { it.key.serverId == serverId }?.value?.refresh + } } diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index f659fcbfb..8511c0b1c 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -77,6 +77,7 @@ kotlin { implementation(libs.koin.compose.viewmodel) implementation(libs.coil.compose) implementation(libs.coil.network.ktor) + implementation(libs.jsoup) implementation(libs.media3.exoplayer) implementation(libs.media3.exoplayer.hls) implementation(libs.media3.datasource.okhttp) @@ -86,6 +87,7 @@ kotlin { implementation(libs.media3.ui.compose) implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.webkit) implementation(libs.koin.androidx.workmanager) implementation(libs.firebase.messaging) // Google Cast (Chromecast) — phone app only. TV app must not depend diff --git a/androidApp/gradle.lockfile b/androidApp/gradle.lockfile index 6ca25434b..c8b77996f 100644 --- a/androidApp/gradle.lockfile +++ b/androidApp/gradle.lockfile @@ -202,6 +202,7 @@ androidx.vectordrawable:vectordrawable-animated:1.1.0=allInstrumentedTestSourceS androidx.vectordrawable:vectordrawable:1.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.versionedparcelable:versionedparcelable:1.1.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.viewpager:viewpager:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.webkit:webkit:1.16.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.work:work-runtime-ktx:2.11.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.work:work-runtime:2.11.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath co.touchlab:stately-concurrency-jvm:2.1.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath @@ -556,6 +557,7 @@ org.jetbrains.skiko:skiko:0.9.4=allInstrumentedTestSourceSetsCompileDependencies org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathAndroidBenchmarkRelease,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidNonMinifiedRelease,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath org.jetbrains:annotations:23.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.json:json:20240303=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jsoup:jsoup:1.22.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jspecify:jspecify:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.ow2.asm:asm-commons:9.8=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.ow2.asm:asm-tree:9.8=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath diff --git a/androidApp/src/androidMain/assets/reader/reflow/paginator.js b/androidApp/src/androidMain/assets/reader/reflow/paginator.js index ca2839119..ae5d7d557 100644 --- a/androidApp/src/androidMain/assets/reader/reflow/paginator.js +++ b/androidApp/src/androidMain/assets/reader/reflow/paginator.js @@ -2,6 +2,7 @@ var root = document.getElementById('reflow-root'); var styleEl = document.getElementById('reflow-style'); var page = 0, pageCount = 1; + var privateOrigin = 'https://appassets.androidplatform.net'; // Viewport in CSS px, pushed from the Android side (Compose-measured size). // Android WebView resolves `vh`/`vw` to 0 when the page loads before the view // is laid out, so we never rely on viewport units for the page box. @@ -46,15 +47,51 @@ img.addEventListener('load', onLoad); }); } + function rewriteResourceUrls(html, baseUrl){ + var template = document.createElement('template'); + template.innerHTML = html; + if (!baseUrl) return template.innerHTML; + + var base; + try { + base = new URL(baseUrl, privateOrigin); + var bookPathMatch = base.pathname.match(/^\/epub\/(epub-[0-9a-f]{40})(?:\/|$)/); + if (base.origin !== privateOrigin || !bookPathMatch) { + return template.innerHTML; + } + var allowedBookPath = '/epub/' + bookPathMatch[1] + '/'; + } catch (e) { + return template.innerHTML; + } + + var attributes = ['href', 'src', 'xlink:href']; + Array.prototype.forEach.call(template.content.querySelectorAll('*'), function(element){ + attributes.forEach(function(attribute){ + if (!element.hasAttribute(attribute)) return; + var value = element.getAttribute(attribute); + if (!value || value.charAt(0) === '#') return; + try { + var resolved = new URL(value, base); + if (resolved.origin === privateOrigin && resolved.pathname.indexOf(allowedBookPath) === 0) { + element.setAttribute(attribute, resolved.href); + } else { + element.removeAttribute(attribute); + } + } catch (e) { + element.removeAttribute(attribute); + } + }); + }); + return template.innerHTML; + } window.ReflowApi = { setViewport: function(w, h){ vpW = w; vpH = h; applyViewport(); requestAnimationFrame(remeasureKeepingProgress); }, load: function(html, baseUrl){ - var b = document.querySelector('base'); if(!b){ b=document.createElement('base'); document.head.appendChild(b);} - if(baseUrl) b.href = baseUrl; - root.innerHTML = html; remeasureAfterPendingImages(); page = 0; applyViewport(); apply(); + root.innerHTML = rewriteResourceUrls(html, baseUrl); + remeasureAfterPendingImages(); page = 0; applyViewport(); apply(); requestAnimationFrame(function(){ requestAnimationFrame(function(){ measure(); apply(); relocate(); }); }); }, goToPage: function(n){ page = Math.min(Math.max(0, n), pageCount-1); apply(); relocate(); }, diff --git a/androidApp/src/androidMain/assets/reader/reflow/reader.html b/androidApp/src/androidMain/assets/reader/reflow/reader.html index 3037b29e4..0fbcb114b 100644 --- a/androidApp/src/androidMain/assets/reader/reflow/reader.html +++ b/androidApp/src/androidMain/assets/reader/reflow/reader.html @@ -1,5 +1,6 @@ + + + + + + """.trimIndent() + + val sanitized = sanitizeEpubChapterHtml(html) + val sanitizedDocument = Jsoup.parseBodyFragment(sanitized) + + assertTrue(sanitizedDocument.select("style, script").isEmpty()) + assertTrue(sanitizedDocument.select("[onerror]").isEmpty()) + assertFalse(sanitized.contains("svg-owned", ignoreCase = true)) + } + + @Test + fun sanitizerRemovesActiveDocumentsAndExternalStyleResources() { + val html = """ + + + + +
+ + + + + +

Readable text

+ """.trimIndent() + + val sanitized = sanitizeEpubChapterHtml(html) + + listOf( + "base", + "link", + "style", + "script", + "form", + "input", + "button", + "iframe", + "frame", + "frameset", + "object", + "embed", + ).forEach { tag -> + assertFalse(sanitized.contains("<$tag", ignoreCase = true), "kept <$tag>") + } + assertFalse(sanitized.contains("srcdoc", ignoreCase = true)) + assertFalse(sanitized.contains("onanimationstart", ignoreCase = true)) + assertFalse(sanitized.contains("style=", ignoreCase = true)) + assertFalse(sanitized.contains("tracker.css", ignoreCase = true)) + assertFalse(sanitized.contains("chapter.js", ignoreCase = true)) + assertTrue(sanitized.contains("Readable text")) + } + + @Test + fun sanitizerRejectsEncodedAndNonRelativeResourceUrls() { + val html = """ + percent encoded script + double encoded script + + + + + entity encoded script + email + """.trimIndent() + + val sanitized = sanitizeEpubChapterHtml(html) + val sanitizedDocument = Jsoup.parseBodyFragment(sanitized) + + assertTrue(sanitizedDocument.select("[href], [src], [xlink:href]").isEmpty()) + } + + @Test + fun sanitizerPreservesRichEpubMarkupAndRelativeResources() { + val html = """ +
+

Chapter (one)

+

Diagram

+ + Safe diagram + + + + + + x=12 + + + + + +
Values
Name
Example
+ Next chapter + Chapter start + Cover +
+ """.trimIndent() + + val sanitized = sanitizeEpubChapterHtml(html) + + listOf( + "", + "", + " + assertTrue(sanitized.contains(element, ignoreCase = true), "removed $element") + } + assertTrue(sanitized.contains("../images/shapes.svg#shape")) + assertTrue(sanitized.contains("../text/chapter-2.xhtml#start")) + assertTrue(sanitized.contains("href=\"#chapter-one\"")) + assertTrue(sanitized.contains("../images/cover.jpg")) + } + + @Test + fun sanitizerRejectsNonLocalSvgPaintUrls() { + val html = """ + + + + + + text + + """.trimIndent() + + val sanitizedDocument = Jsoup.parseBodyFragment(sanitizeEpubChapterHtml(html)) + + assertTrue(sanitizedDocument.select("[fill], [stroke]").isEmpty()) + } + + @Test + fun sanitizerPreservesSafeSvgPaintValues() { + val html = """ + + + + + + + + + + text + + """.trimIndent() + + val sanitizedDocument = Jsoup.parseBodyFragment(sanitizeEpubChapterHtml(html)) + + assertEquals("url(#gradient)", sanitizedDocument.getElementById("local-paint")?.attr("fill")) + assertEquals("url('#outline')", sanitizedDocument.getElementById("local-paint")?.attr("stroke")) + assertEquals("#336699", sanitizedDocument.getElementById("colors")?.attr("fill")) + assertEquals("currentColor", sanitizedDocument.getElementById("colors")?.attr("stroke")) + assertEquals("red", sanitizedDocument.getElementById("keywords")?.attr("fill")) + assertEquals("none", sanitizedDocument.getElementById("keywords")?.attr("stroke")) + assertEquals( + "rgb(10 20 30 / 50%)", + sanitizedDocument.getElementById("functional-colors")?.attr("fill"), + ) + assertEquals( + "hsl(120 100% 50%)", + sanitizedDocument.getElementById("functional-colors")?.attr("stroke"), + ) + } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/reader/reflow/EpubReflowSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/reader/reflow/EpubReflowSourceTest.kt index 020db70ce..452315e8f 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/reader/reflow/EpubReflowSourceTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/reader/reflow/EpubReflowSourceTest.kt @@ -49,8 +49,12 @@ class EpubReflowSourceTest { val source = EpubReflowSource(book) assertTrue( - source.baseUrl(0).endsWith("/OEBPS/xhtml/"), - "Relative EPUB resources should resolve from the current chapter directory.", + source.baseUrl(0).matches( + Regex( + """https://appassets\.androidplatform\.net/epub/epub-[0-9a-f]{40}/OEBPS/xhtml/""", + ), + ), + "Relative EPUB resources should resolve from the current chapter directory on the private origin.", ) } finally { tempDir.deleteRecursively() diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/reader/reflow/EpubResourcePathHandlerTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/reader/reflow/EpubResourcePathHandlerTest.kt new file mode 100644 index 000000000..37848e0fc --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/reader/reflow/EpubResourcePathHandlerTest.kt @@ -0,0 +1,190 @@ +package org.siloserver.silo.android.ui.screens.reader.reflow + +import java.nio.file.Files +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = android.app.Application::class) +class EpubResourcePathHandlerTest { + @Test + fun servesCanonicalFilesFromHashedEpubDirectory() { + withFixture { fixture -> + val image = java.io.File(fixture.bookRoot, "OEBPS/images/cover.jpg") + requireNotNull(image.parentFile).mkdirs() + image.writeBytes(byteArrayOf(1, 2, 3)) + + val response = fixture.handler.handle("${fixture.bookRoot.name}/OEBPS/images/cover.jpg") + + assertEquals(200, response.statusCode) + assertEquals("image/jpeg", response.mimeType) + assertContentEquals(byteArrayOf(1, 2, 3), response.data.readBytes()) + } + } + + @Test + fun returnsEmpty404ForTraversalAndOutOfRootPaths() { + withFixture { fixture -> + val outside = java.io.File(fixture.root.parentFile, "outside-reader-secret.txt") + outside.writeText("secret") + try { + listOf( + "../${outside.name}", + "%2e%2e/${outside.name}", + "%252e%252e%252f${outside.name}", + "/${outside.absolutePath}", + "${fixture.bookRoot.name}/../../${outside.name}", + "not-an-epub/private.txt", + ).forEach { path -> + val response = fixture.handler.handle(path) + assertEquals(404, response.statusCode, "accepted $path") + assertContentEquals(byteArrayOf(), response.data.readBytes(), "non-empty 404 for $path") + } + } finally { + outside.delete() + } + } + } + + @Test + fun rejectsAValidNormalizedPathBelongingToAnotherCachedBook() { + withFixture { fixture -> + val otherRoot = fixture.root.resolve("epub-${"b".repeat(40)}").apply { mkdirs() } + val otherChapter = otherRoot.resolve("OEBPS/private.xhtml") + requireNotNull(otherChapter.parentFile).mkdirs() + otherChapter.writeText("other book") + + assertEmpty404( + fixture.handler.handle("${otherRoot.name}/OEBPS/private.xhtml"), + otherChapter.path, + ) + } + } + + @Test + fun returnsEmpty404ForSymlinksEvenWhenTheirTargetIsInsideRoot() { + withFixture { fixture -> + val target = java.io.File(fixture.bookRoot, "OEBPS/images/cover.jpg") + requireNotNull(target.parentFile).mkdirs() + target.writeText("cover") + val link = java.io.File(fixture.bookRoot, "OEBPS/images/link.jpg") + Files.createSymbolicLink(link.toPath(), target.toPath()) + + val response = fixture.handler.handle("${fixture.bookRoot.name}/OEBPS/images/link.jpg") + + assertEquals(404, response.statusCode) + assertContentEquals(byteArrayOf(), response.data.readBytes()) + } + } + + @Test + fun returnsEmpty404ForIntermediateAndOutsideSymlinks() { + withFixture { fixture -> + val realImages = java.io.File(fixture.bookRoot, "OEBPS/real-images").apply { mkdirs() } + java.io.File(realImages, "inside.jpg").writeText("inside") + val intermediateLink = java.io.File(fixture.bookRoot, "OEBPS/images") + Files.createSymbolicLink(intermediateLink.toPath(), realImages.toPath()) + + val outsideDirectory = createTempDirectory("outside-epub-resource").toFile() + try { + java.io.File(outsideDirectory, "outside.jpg").writeText("outside") + val outsideLink = java.io.File(fixture.bookRoot, "OEBPS/outside") + Files.createSymbolicLink(outsideLink.toPath(), outsideDirectory.toPath()) + + listOf( + "${fixture.bookRoot.name}/OEBPS/images/inside.jpg", + "${fixture.bookRoot.name}/OEBPS/outside/outside.jpg", + ).forEach { path -> + assertEmpty404(fixture.handler.handle(path), path) + } + } finally { + outsideDirectory.deleteRecursively() + } + } + } + + @Test + fun returnsEmpty404ForEncodedSeparatorsInvalidEscapesAndNul() { + withFixture { fixture -> + val cover = java.io.File(fixture.bookRoot, "OEBPS/images/cover.jpg") + requireNotNull(cover.parentFile).mkdirs() + cover.writeText("cover") + + listOf( + "${fixture.bookRoot.name}%2fOEBPS%2fimages%2fcover.jpg", + "${fixture.bookRoot.name}%252fOEBPS%252fimages%252fcover.jpg", + "${fixture.bookRoot.name}%5cOEBPS%5cimages%5ccover.jpg", + "${fixture.bookRoot.name}%255cOEBPS%255cimages%255ccover.jpg", + "${fixture.bookRoot.name}/OEBPS/images/%", + "${fixture.bookRoot.name}/OEBPS/images/%2", + "${fixture.bookRoot.name}/OEBPS/images/%00cover.jpg", + ).forEach { path -> + assertEmpty404(fixture.handler.handle(path), path) + } + } + } + + @Test + fun returnsEmpty404ForDirectoriesAndMissingFiles() { + withFixture { fixture -> + java.io.File(fixture.bookRoot, "OEBPS/images").mkdirs() + + listOf( + fixture.bookRoot.name, + "${fixture.bookRoot.name}/OEBPS/images", + "${fixture.bookRoot.name}/OEBPS/images/missing.jpg", + ).forEach { path -> + assertEmpty404(fixture.handler.handle(path), path) + } + } + } + + @Test + fun reportsKnownAndUnknownMimeTypesWithoutGuessing() { + withFixture { fixture -> + val styles = java.io.File(fixture.bookRoot, "OEBPS/styles/book.css") + requireNotNull(styles.parentFile).mkdirs() + styles.writeText("body{}") + val unknown = java.io.File(fixture.bookRoot, "OEBPS/resources/blob.bin") + requireNotNull(unknown.parentFile).mkdirs() + unknown.writeBytes(byteArrayOf(7)) + + val cssResponse = fixture.handler.handle("${fixture.bookRoot.name}/OEBPS/styles/book.css") + val unknownResponse = fixture.handler.handle("${fixture.bookRoot.name}/OEBPS/resources/blob.bin") + + assertEquals(200, cssResponse.statusCode) + assertEquals("text/css", cssResponse.mimeType) + assertEquals("UTF-8", cssResponse.encoding) + assertEquals(200, unknownResponse.statusCode) + assertEquals("application/octet-stream", unknownResponse.mimeType) + assertNull(unknownResponse.encoding) + } + } + + private fun assertEmpty404(response: android.webkit.WebResourceResponse, path: String) { + assertEquals(404, response.statusCode, "accepted $path") + assertContentEquals(byteArrayOf(), response.data.readBytes(), "non-empty 404 for $path") + } + + private fun withFixture(block: (Fixture) -> Unit) { + val root = createTempDirectory("epub-resource-root").toFile() + try { + val bookRoot = root.resolve("epub-${"a".repeat(40)}").apply { mkdirs() } + block(Fixture(root, bookRoot, EpubResourcePathHandler(root, bookRoot.name))) + } finally { + root.deleteRecursively() + } + } + + private data class Fixture( + val root: java.io.File, + val bookRoot: java.io.File, + val handler: EpubResourcePathHandler, + ) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt index f83af930d..712f6f073 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt @@ -40,6 +40,7 @@ import org.siloserver.silo.common.ui.components.StartupSplashVideo import org.siloserver.silo.common.ui.components.StartupSplashResizeMode import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.requiresApproval import org.siloserver.silo.repository.AuthRepository import org.siloserver.silo.repository.PersonalDataRepository import org.siloserver.silo.repository.ProfileRepository @@ -270,6 +271,13 @@ class MainTvActivity : ComponentActivity() { val activeEntry = registry.activeEntry.value ?: return TvRoute.ServerSetup.route + val cleartextConsent = get( + org.siloserver.silo.network.CleartextOriginConsent::class.java, + ) + if (cleartextConsent.requiresApproval(activeEntry.url)) { + return TvRoute.ServerSetup.route + } + val accessToken = tokenManager.getAccessToken() if (accessToken.isNullOrBlank()) return TvRoute.Login().route diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index d116409e0..6d9256fa5 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -7,6 +7,8 @@ import org.siloserver.silo.repository.SettingsRepository import org.siloserver.silo.tv.data.preferences.LegacyTvPrefsMigration import org.siloserver.silo.tv.data.preferences.TvLibrarySelectionStore import org.siloserver.silo.common.network.AndroidDeviceMetadataProvider +import org.siloserver.silo.common.network.CleartextConsentStore +import org.siloserver.silo.common.network.DataStoreCleartextConsentStore import org.siloserver.silo.common.settings.AndroidServerSettingsCache import android.content.SharedPreferences import org.siloserver.silo.network.AndroidServerRegistry @@ -71,6 +73,8 @@ import org.koin.dsl.module * [org.siloserver.silo.tv.SiloTvApplication]. */ val androidTvModule = module { + single { DataStoreCleartextConsentStore(androidContext()) } + single { get() } // Single encrypted prefs handle shared between the server registry and // the token manager — see the phone module for rationale. single { createSecureSharedPrefs(androidContext()) } @@ -265,7 +269,7 @@ val androidTvModule = module { // machine. A later step wires the UI to PairingReceiver.status. single { org.siloserver.silo.common.pairing.PairingReceiver( - authPort = org.siloserver.silo.common.pairing.RegistryPairingAuthPort(get(), get()), + authPort = org.siloserver.silo.common.pairing.RegistryPairingAuthPort(get(), get(), get()), deviceLogin = org.siloserver.silo.common.pairing.DeviceLoginRepositoryPort(get()), identityProvider = { org.siloserver.silo.common.pairing.PairingDeviceIdentity( @@ -319,7 +323,7 @@ val androidTvModule = module { } // Auth ViewModels - viewModel { TvServerSetupViewModel(get()) } + viewModel { TvServerSetupViewModel(get(), get()) } viewModel { org.siloserver.silo.tv.ui.screens.auth.TvSetupViewModel(get()) } viewModel { org.siloserver.silo.tv.ui.screens.auth.TvSignupViewModel(get()) } viewModel { TvLoginViewModel(get(), get(), get()) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupScreen.kt index a154fe858..048d7a109 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupScreen.kt @@ -75,6 +75,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.AlertDialog import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme @@ -169,6 +170,36 @@ fun TvServerSetupScreen( } } + state.pendingCleartextUrl?.let { origin -> + AlertDialog( + onDismissRequest = viewModel::cancelCleartextConnection, + title = { Text("Use unencrypted HTTP?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(origin, fontWeight = FontWeight.SemiBold) + Text( + "This connection is not encrypted. Anyone on the network may see or change " + + "traffic, including your sign-in. Continue only on a network you trust.", + ) + } + }, + confirmButton = { + AuroraPrimaryButton( + label = "Use HTTP", + onClick = viewModel::confirmCleartextConnection, + modifier = Modifier.width(180.dp), + enabled = !state.isLoading, + ) + }, + dismissButton = { + AuroraGhostButton( + label = "Cancel", + onClick = viewModel::cancelCleartextConnection, + ) + }, + ) + } + Box( modifier = Modifier .fillMaxSize() diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupViewModel.kt index 82fac3404..c8abe6aa1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupViewModel.kt @@ -2,6 +2,8 @@ package org.siloserver.silo.tv.ui.screens.auth import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import org.siloserver.silo.common.network.CleartextConsentStore +import org.siloserver.silo.common.network.cleartextOrigin import org.siloserver.silo.model.auth.SetupStatusResponse import org.siloserver.silo.model.auth.SignupStatusResponse import org.siloserver.silo.network.AndroidServerRegistry @@ -19,6 +21,7 @@ data class TvServerSetupUiState( val error: String? = null, /** Destination after a successful server probe; consumed by the screen. */ val navigateTo: TvServerSetupDestination? = null, + val pendingCleartextUrl: String? = null, ) { /** True when the entered address will connect over unencrypted HTTP. * Informational only — does not block LAN/IP connections. */ @@ -64,10 +67,18 @@ internal sealed class TvServerSetupProbeResult { */ class TvServerSetupViewModel( private val authRepository: AuthRepository, + private val cleartextConsentStore: CleartextConsentStore, + private val getSetupStatus: suspend (String) -> ApiResult = { + authRepository.getSetupStatus(it) + }, + private val getSignupStatus: suspend (String) -> ApiResult = { + authRepository.getSignupStatus(it) + }, ) : ViewModel() { private val _uiState = MutableStateFlow(TvServerSetupUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var pendingCleartextConnection: PendingTvCleartextConnection? = null init { viewModelScope.launch { @@ -100,22 +111,18 @@ class TvServerSetupViewModel( val candidates = serverSetupUrlProbeCandidates(raw) viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } + pendingCleartextConnection = null + _uiState.update { + it.copy(isLoading = true, error = null, pendingCleartextUrl = null) + } when (val result = probeTvServerSetupCandidates( candidates = candidates, - getSetupStatus = { candidate -> authRepository.getSetupStatus(candidate) }, - getSignupStatus = { candidate -> authRepository.getSignupStatus(candidate) }, + getSetupStatus = getSetupStatus, + getSignupStatus = getSignupStatus, )) { is TvServerSetupProbeResult.Success -> { - authRepository.setServerUrl(result.serverUrl) - _uiState.update { - it.copy( - serverUrl = result.serverUrl, - isLoading = false, - navigateTo = result.destination, - ) - } + handleSuccessfulConnection(result.serverUrl, result.destination) } is TvServerSetupProbeResult.Failure -> { _uiState.update { @@ -130,11 +137,89 @@ class TvServerSetupViewModel( } } + fun confirmCleartextConnection() { + if (_uiState.value.isLoading) return + val pending = pendingCleartextConnection ?: return + _uiState.update { it.copy(isLoading = true, error = null) } + viewModelScope.launch { + cleartextConsentStore.approve(pending.origin) + if (pendingCleartextConnection !== pending) return@launch + pendingCleartextConnection = null + persistAndNavigate(pending.serverUrl, pending.destination) + } + } + + fun cancelCleartextConnection() { + pendingCleartextConnection = null + _uiState.update { + it.copy(pendingCleartextUrl = null, isLoading = false) + } + } + fun onNavigationConsumed() { _uiState.update { it.copy(navigateTo = null) } } + + private suspend fun handleSuccessfulConnection( + serverUrl: String, + destination: TvServerSetupDestination, + ) { + if (serverUrl.startsWith("http://", ignoreCase = true)) { + val origin = cleartextOrigin(serverUrl) + if (origin == null) { + _uiState.update { + it.copy( + isLoading = false, + error = "Could not safely identify this HTTP server.", + pendingCleartextUrl = null, + navigateTo = null, + ) + } + return + } + if (cleartextConsentStore.isApproved(origin)) { + persistAndNavigate(serverUrl, destination) + return + } + pendingCleartextConnection = PendingTvCleartextConnection( + serverUrl = serverUrl, + origin = origin, + destination = destination, + ) + _uiState.update { + it.copy( + isLoading = false, + pendingCleartextUrl = origin, + navigateTo = null, + ) + } + return + } + persistAndNavigate(serverUrl, destination) + } + + private suspend fun persistAndNavigate( + serverUrl: String, + destination: TvServerSetupDestination, + ) { + authRepository.setServerUrl(serverUrl) + _uiState.update { + it.copy( + serverUrl = serverUrl, + isLoading = false, + pendingCleartextUrl = null, + navigateTo = destination, + ) + } + } } +private data class PendingTvCleartextConnection( + val serverUrl: String, + val origin: String, + val destination: TvServerSetupDestination, +) + internal suspend fun probeTvServerSetupCandidates( candidates: List, getSetupStatus: suspend (String) -> ApiResult, diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupPersistenceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupPersistenceTest.kt index 410ff6ecc..c82f3aca2 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupPersistenceTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupPersistenceTest.kt @@ -7,23 +7,33 @@ import io.ktor.serialization.kotlinx.json.json import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import androidx.lifecycle.viewModelScope import kotlinx.coroutines.cancel import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout import org.siloserver.silo.network.SiloAuthPlugin import org.siloserver.silo.network.SiloJson import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.api.AuthApi +import org.siloserver.silo.common.network.CleartextConsentStore +import org.siloserver.silo.model.auth.SetupStatusResponse +import org.siloserver.silo.model.auth.SignupStatusResponse import org.siloserver.silo.repository.AuthRepository import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull @OptIn(ExperimentalCoroutinesApi::class) class TvServerSetupPersistenceTest { @@ -70,7 +80,7 @@ class TvServerSetupPersistenceTest { ), tokenManager = tokenManager, ) - val viewModel = track(TvServerSetupViewModel(repository)) + val viewModel = track(TvServerSetupViewModel(repository, FakeCleartextConsentStore())) viewModel.onServerUrlChanged("bad.silo") viewModel.onConnectClick() @@ -83,10 +93,155 @@ class TvServerSetupPersistenceTest { "Failed probes must not persist candidate URLs.", ) } + + @Test + fun successfulHttpFallbackStopsForConfirmationBeforePersistence() = runTest(dispatcher) { + val tokenManager = RecordingTokenManager() + val viewModel = track(viewModelFor(tokenManager)) + + viewModel.onServerUrlChanged("silo.lan") + viewModel.onConnectClick() + awaitSettled(viewModel) + + assertEquals("http://silo.lan", viewModel.uiState.value.pendingCleartextUrl) + assertEquals(emptyList(), tokenManager.serverUrlWrites) + assertNull(viewModel.uiState.value.navigateTo) + + viewModel.confirmCleartextConnection() + awaitSettled(viewModel) + + assertEquals(listOf("http://silo.lan"), tokenManager.serverUrlWrites) + assertEquals(TvServerSetupDestination.Login(signupEnabled = true), viewModel.uiState.value.navigateTo) + } + + @Test + fun cancelCleartextConnectionClearsPendingWithoutPersistence() = runTest(dispatcher) { + val tokenManager = RecordingTokenManager() + val viewModel = track(viewModelFor(tokenManager)) + + viewModel.onServerUrlChanged("silo.lan") + viewModel.onConnectClick() + awaitSettled(viewModel) + viewModel.cancelCleartextConnection() + + assertNull(viewModel.uiState.value.pendingCleartextUrl) + assertEquals(emptyList(), tokenManager.serverUrlWrites) + assertNull(viewModel.uiState.value.navigateTo) + } + + @Test + fun cleartextApprovalIsOriginSpecific() = runTest(dispatcher) { + val tokenManager = RecordingTokenManager() + val approvals = FakeCleartextConsentStore().apply { approve("http://other.lan") } + val viewModel = track(viewModelFor(tokenManager, approvals)) + + viewModel.onServerUrlChanged("silo.lan") + viewModel.onConnectClick() + awaitSettled(viewModel) + + assertEquals("http://silo.lan", viewModel.uiState.value.pendingCleartextUrl) + assertEquals(emptyList(), tokenManager.serverUrlWrites) + } + + @Test + fun priorOriginApprovalAndHttpsBypassConfirmation() = runTest(dispatcher) { + val approvedTokens = RecordingTokenManager() + val approvals = FakeCleartextConsentStore().apply { approve("http://silo.lan") } + val approvedViewModel = track(viewModelFor(approvedTokens, approvals)) + + approvedViewModel.onServerUrlChanged("silo.lan") + approvedViewModel.onConnectClick() + awaitSettled(approvedViewModel) + + assertNull(approvedViewModel.uiState.value.pendingCleartextUrl) + assertEquals(listOf("http://silo.lan"), approvedTokens.serverUrlWrites) + + val httpsTokens = RecordingTokenManager() + val httpsViewModel = track(viewModelFor(httpsTokens, httpsSucceeds = true)) + httpsViewModel.onServerUrlChanged("secure.silo") + httpsViewModel.onConnectClick() + awaitSettled(httpsViewModel) + + assertNull(httpsViewModel.uiState.value.pendingCleartextUrl) + assertEquals(listOf("https://secure.silo"), httpsTokens.serverUrlWrites) + } + + @Test + fun cancelWinsRaceWithInFlightCleartextApproval() = runTest(dispatcher) { + val tokenManager = RecordingTokenManager() + val approvals = BlockingCleartextConsentStore() + val viewModel = track(viewModelFor(tokenManager, approvals)) + viewModel.onServerUrlChanged("silo.lan") + viewModel.onConnectClick() + awaitSettled(viewModel) + + viewModel.confirmCleartextConnection() + runCurrent() + approvals.approveStarted.await() + viewModel.cancelCleartextConnection() + approvals.releaseApproval.complete(Unit) + advanceUntilIdle() + + assertNull(viewModel.uiState.value.pendingCleartextUrl) + assertNull(viewModel.uiState.value.navigateTo) + assertEquals(emptyList(), tokenManager.serverUrlWrites) + } + + @Test + fun successfulUnnormalizableHttpCandidateFailsClosed() = runTest(dispatcher) { + val tokenManager = RecordingTokenManager() + val viewModel = track(viewModelFor(tokenManager)) + + viewModel.onServerUrlChanged("http://silo_lan") + viewModel.onConnectClick() + awaitSettled(viewModel) + + assertEquals(emptyList(), tokenManager.serverUrlWrites) + assertNull(viewModel.uiState.value.navigateTo) + assertEquals("Could not safely identify this HTTP server.", viewModel.uiState.value.error) + } + + private fun viewModelFor( + tokenManager: RecordingTokenManager, + approvals: FakeCleartextConsentStore = FakeCleartextConsentStore(), + httpsSucceeds: Boolean = false, + ) = TvServerSetupViewModel( + authRepository = repositoryFor(tokenManager), + cleartextConsentStore = approvals, + getSetupStatus = { candidate -> + if (candidate.startsWith("https://") && !httpsSucceeds) { + ApiResult.NetworkError(IOException("TLS unavailable")) + } else { + ApiResult.Success(SetupStatusResponse(needsSetup = false)) + } + }, + getSignupStatus = { + ApiResult.Success(SignupStatusResponse(enabled = true)) + }, + ) + + private fun repositoryFor( + tokenManager: RecordingTokenManager, + ): AuthRepository = AuthRepository( + authApi = AuthApi( + HttpClient(MockEngine { throw IOException("Unexpected network request") }) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { this.tokenManager = tokenManager } + }, + ), + tokenManager = tokenManager, + ) + + private suspend fun TestScope.awaitSettled(viewModel: TvServerSetupViewModel) { + runCurrent() + withTimeout(5_000) { + viewModel.uiState.first { !it.isLoading } + } + } } private class RecordingTokenManager( - private var serverUrl: String, + private var serverUrl: String = "", ) : TokenManager { val serverUrlWrites = mutableListOf() override val sessionExpired = MutableSharedFlow() @@ -108,3 +263,24 @@ private class RecordingTokenManager( override suspend fun switchActiveServer(serverId: String?) = Unit override suspend fun signOutCurrentServer() = Unit } + +private open class FakeCleartextConsentStore : CleartextConsentStore { + private val approved = mutableSetOf() + + override suspend fun isApproved(origin: String): Boolean = origin in approved + + override suspend fun approve(origin: String) { + approved += origin + } +} + +private class BlockingCleartextConsentStore : FakeCleartextConsentStore() { + val approveStarted = CompletableDeferred() + val releaseApproval = CompletableDeferred() + + override suspend fun approve(origin: String) { + approveStarted.complete(Unit) + releaseApproval.await() + super.approve(origin) + } +} diff --git a/baselineprofile/gradle.lockfile b/baselineprofile/gradle.lockfile index 55fbc8cc6..16705f7e0 100644 --- a/baselineprofile/gradle.lockfile +++ b/baselineprofile/gradle.lockfile @@ -172,6 +172,7 @@ androidx.vectordrawable:vectordrawable-animated:1.1.0=benchmarkReleaseTestedApks androidx.vectordrawable:vectordrawable:1.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks androidx.versionedparcelable:versionedparcelable:1.1.1=benchmarkReleaseRuntimeClasspath,benchmarkReleaseTestedApks,nonMinifiedReleaseRuntimeClasspath,nonMinifiedReleaseTestedApks androidx.viewpager:viewpager:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.webkit:webkit:1.16.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks androidx.work:work-runtime-ktx:2.11.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks androidx.work:work-runtime:2.11.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks co.touchlab:stately-concurrency-jvm:2.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks @@ -505,6 +506,7 @@ org.jetbrains.skiko:skiko-awt:0.9.4=benchmarkReleaseTestedApks,nonMinifiedReleas org.jetbrains.skiko:skiko:0.9.4=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath org.jetbrains:annotations:23.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,benchmarkReleaseTestedApks,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,nonMinifiedReleaseTestedApks +org.jsoup:jsoup:1.22.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks org.jspecify:jspecify:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks org.slf4j:slf4j-api:2.0.16=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks empty=androidApis,androidJdkImage,androidTestUtil,benchmarkReleaseAnnotationProcessorClasspath,benchmarkReleaseApiDependenciesMetadata,benchmarkReleaseCompileOnlyDependenciesMetadata,benchmarkReleaseImplementationDependenciesMetadata,benchmarkReleaseIntransitiveDependenciesMetadata,compileOnlyDependenciesMetadata,coreLibraryDesugaring,debugApiDependenciesMetadata,debugCompileOnlyDependenciesMetadata,debugImplementationDependenciesMetadata,debugIntransitiveDependenciesMetadata,intransitiveDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathBenchmarkRelease,kotlinCompilerPluginClasspathNonMinifiedRelease,kotlinNativeCompilerPluginClasspath,lintChecks,lintPublish,nonMinifiedReleaseAnnotationProcessorClasspath,nonMinifiedReleaseApiDependenciesMetadata,nonMinifiedReleaseCompileOnlyDependenciesMetadata,nonMinifiedReleaseImplementationDependenciesMetadata,nonMinifiedReleaseIntransitiveDependenciesMetadata diff --git a/docs/notes/2026-07-27-pr108-slice-b-traceability.md b/docs/notes/2026-07-27-pr108-slice-b-traceability.md new file mode 100644 index 000000000..b63fd0675 --- /dev/null +++ b/docs/notes/2026-07-27-pr108-slice-b-traceability.md @@ -0,0 +1,89 @@ +# PR 108 Forward Split: Slice B Traceability + +Slice B is stacked on reviewed slice A: + +```text +base 5a34a95518047daa698acd4e80cde3571e6b0c8c +head split/108-b-auth-epub +``` + +PR 108 remains the archival integration reference. This branch contains only +the auth/origin/cleartext and EPUB-security vertical slice plus the dependency +state needed by that slice. + +## Original path/purpose mapping + +| PR 108 commit | Purpose in slice B | Forward-split disposition | +| --- | --- | --- | +| `9597eeee` | Auth refresh/session-safety prerequisite | Relevant `AuthInterceptorImpl` net state is carried by `aeead9d9` | +| `65c4b316` | Credential-generation scope prerequisite | Relevant token/scope net state is carried by `aeead9d9` | +| `1fecf9b1` | Central HTTP origin model | Folded with its follow-up into `38fbf837` | +| `cbf398fa` | Reject ambiguous authorities and normalize safe HTTP origins | Folded with `1fecf9b1` into `38fbf837` | +| `5d5f0562` | Never attach or refresh Silo credentials off-origin | Ported as `aeead9d9` | +| `e18d092e` | Reject stale persistent credential generations | Ported in `aeead9d9`, with startup-snapshot correction in `15278f95` | +| `c07d9f9f` | Require explicit cleartext-origin consent | Ported as `dd1e0992`, with client/media/startup/pairing enforcement in `15278f95` | +| `85890c6d` | Parsed EPUB allowlist sanitizer | Cherry-picked with `-x` as `60d20931` | +| `5db84af8` | Restrict SVG paint references to safe local forms | Cherry-picked with `-x` as `6b35e3c7` | +| `14b41b7f` | Bound downloads, ZIP entries, and extracted content | Cherry-picked with `-x` as `9dce6f10`; strict markup limits in `15278f95` | +| `5a040e0d` | Isolate EPUB resources behind a WebView asset-loader origin | Cherry-picked with `-x` as `1cc0cb79`; exact-book binding in `15278f95` | +| `fd545fab` | Remount reader content when the source changes | Cherry-picked with `-x` as `ffe24bf1` | +| `eda4a4a2` | Remove API-26-only hardened-path calls | Cherry-picked with `-x` as `d0d5eaee` | + +`6240c7fd` resolves the stacked slice's dependency state. The jsoup and +AndroidX WebKit additions produced seven new artifact checksums; all seven +exactly match the independently generated metadata in archival PR 108. + +## Conflict and net-diff decisions + +- The late auth commits assume credential-generation machinery introduced in + `9597eeee` and `65c4b316`. Rather than importing either broad commit, this + slice takes the final PR 108 state only for the affected auth/token paths. +- Current upstream already declares MockWebServer. The duplicate catalog alias + introduced by replaying `85890c6d` was removed; no dependency changed. +- `14b41b7f` predates `5d5f0562`. Replaying it after the auth work malformed + the combined fake data source, so the final PR 108 net state was used for + `AuthenticatedDataSourceFactory` and its test. +- `ReaderEngineHostSourceTest` did not yet exist on current upstream, although + `fd545fab` modifies it. Its complete prerequisite test contract was retained + so the source-remount regression remains executable; no unrelated production + path was imported. + +These decisions intentionally produce different patch IDs for the combined auth +commits while retaining `-x` ancestry for the independently applicable reader +commits. + +## TDD and verification record + +- Origin-policy RED: the adversarial common tests failed to compile without + `HttpOriginPolicy`; GREEN after the centralized parser/comparator was added. +- Credential-scope RED: final tests exposed missing scoped refresh and + generation APIs; GREEN after final auth/token path state was ported. +- Cleartext-consent RED: mobile/TV/store tests failed without an explicit + consent state machine; GREEN after the store, DI, view models, and UI were + added. +- EPUB RED: bounded-stream and reader security tests failed without the new + limits, sanitizer, and isolated resource handler. +- Integration RED: Gradle rejected a duplicate MockWebServer catalog alias + caused by upstream overlap; removal restored a single unchanged alias. +- Integration RED: reverse replay of the reader/auth commits malformed the + authenticated data-source test double; using the final two-commit net state + fixed the ordering conflict. +- Focused GREEN suites cover origin parsing, cross-origin redirects, scoped + refresh, credential replacement, mobile/TV cleartext persistence, stream and + ZIP limits, HTML/SVG sanitization, traversal/encoded traversal, resource + isolation, source remounting, and API-24-compatible paths. +- Independent security review identified five important gaps. `15278f95` + closes them with regression coverage: consent is enforced at shared Ktor, + media, startup, and pairing boundaries; live persistent credential epochs + never use the unstamped sentinel; media refresh pins the complete credential + scope and cannot fall through from a guest overlay to the owner; resource + loading is bound to one exact EPUB cache directory in JavaScript and native + code; and container/package/chapter markup has strict type-specific limits + while section metadata reads only a bounded prefix. +- Follow-up review found two additional cleartext destination gaps. The final + correction checks the actual resolved URL for absolute unauthenticated auth + POSTs, fails closed for every lexical `http:` form (including backslash + separators), and rejects an unapproved Media3 HTTP stream before + server-issued plan headers or query credentials reach the transport. A + network interceptor repeats that check for every redirect follow-up, so + HTTPS-to-HTTP and approved-to-unapproved redirects cannot bypass it. diff --git a/docs/superpowers/plans/2026-07-27-pr108-slice-b-auth-epub.md b/docs/superpowers/plans/2026-07-27-pr108-slice-b-auth-epub.md new file mode 100644 index 000000000..66b497978 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-pr108-slice-b-auth-epub.md @@ -0,0 +1,82 @@ +# PR 108 Slice B: Auth, Origin, Cleartext Consent, and EPUB Security + +**Goal:** Forward-split PR 108's authenticated-origin and EPUB hardening onto +the reviewed slice-A head as one security-focused, compiling vertical slice. + +**Stack:** `split/108-b-auth-epub` targets +`split/108-a-supply-chain`; archival PR 108 remains unchanged. + +**Original commits:** + +- `9597eeee` — prerequisite auth refresh/session-safety state in the client audit +- `65c4b316` — prerequisite credential-generation scope state +- `85890c6d` — parse and sanitize EPUB markup with an allowlist +- `5db84af8` — validate SVG paint references +- `14b41b7f` — bound remote and archive content +- `5a040e0d` — isolate EPUB resources behind WebView asset loading +- `fd545fab` — remount the WebView when its source changes +- `eda4a4a2` — keep hardened EPUB paths compatible with Android API 24 +- `1fecf9b1` — centralize authenticated HTTP origin comparison +- `cbf398fa` — reject ambiguous HTTP authorities +- `5d5f0562` — keep credentials on the configured Silo origin +- `e18d092e` — reject authentication scopes whose credentials were replaced +- `c07d9f9f` — require explicit consent before cleartext login + +## Task 1: Authenticated Origin Policy + +1. Import the final origin-policy tests from `cbf398fa` without production + code and run the shared common tests to record RED. +2. Import the final origin-policy production implementation and run the same + tests to GREEN. +3. Confirm default-port normalization, IPv4/IPv6 handling, user-info + rejection, ambiguous-authority rejection, and HTTPS/HTTP separation. + +## Task 2: Credential Scoping and Replacement Races + +1. Import the tests changed by `5d5f0562` and `e18d092e` first, then run + focused shared and Android-shared tests to record RED. +2. Port only the related token-manager, auth-interceptor, media-auth session, + data-source, and HTTP-client net changes. +3. Run focused tests to GREEN, including redirects, host/port/scheme changes, + token-generation replacement, and reader-file authentication. + +## Task 3: Explicit Cleartext Consent + +1. Import cleartext consent and mobile/TV persistence tests first and run them + to record RED. +2. Port the consent store, dependency injection, setup view-model state + machines, and confirmation UI for mobile and TV. +3. Verify credentials are never persisted or sent before the user confirms + the exact cleartext origin, and that changing the entered origin invalidates + prior consent. + +## Task 4: EPUB Sanitization and Resource Isolation + +1. Import sanitizer/resource-path/WebView tests first and run focused reader + tests to record RED. +2. Port the parsed allowlist sanitizer, isolated asset-loader origin, + resource-path handler, reader HTML/JavaScript bridge changes, and API-24 + compatibility correction. +3. Verify scripts, event handlers, dangerous URLs, traversal, encoded + traversal, external origins, and cross-book paths are rejected while valid + EPUB-local resources remain readable. + +## Task 5: Dependency State, Traceability, and Verification + +1. Regenerate slice-B lockfiles and SHA-256 metadata after adding parser and + WebView dependencies; never copy later-slice dependency state wholesale. +2. Add a path/purpose traceability note mapping all eight original commits to + forward-split commits and document any conflict-resolution deviations. +3. Run: + + - focused security tests from Tasks 1–4 + - `./scripts/test-check-build-supply-chain.sh` + - `./scripts/check-build-supply-chain.sh` + - `./gradlew testDebugUnitTest` + - `./gradlew :androidApp:assembleRelease :androidTvApp:assembleRelease` + - `git diff --check` + +4. Obtain independent code/security review and fix all Critical or Important + findings. +5. Push the branch and open a draft stacked PR against + `split/108-a-supply-chain`. Do not merge either PR. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 57c22da63..10598c419 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ robolectric = "4.16.1" ksp = "2.1.20-2.0.1" room = "2.8.4" androidx-test-core = "1.6.1" +androidx-webkit = "1.16.0" bouncycastle = "1.84" profileinstaller = "1.4.1" benchmark = "1.3.4" @@ -35,6 +36,7 @@ firebase-messaging = "25.1.0" google-services = "4.5.0" junit4 = "4.13.2" okhttp = "4.12.0" +jsoup = "1.22.2" # Google Cast (Chromecast, phone app only) + its MediaRouter dependency. play-services-cast-framework = "21.5.0" androidx-mediarouter = "1.7.0" @@ -50,6 +52,7 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" } +androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" } # Ktor ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } @@ -125,6 +128,7 @@ androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "a androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } firebase-messaging = { module = "com.google.firebase:firebase-messaging", version.ref = "firebase-messaging" } junit4 = { module = "junit:junit", version.ref = "junit4" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } # Google Cast (Chromecast) — phone app only. Tier 2 casting starts a separate # cast-capability playback session; the raw phone stream is never cast. diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index c8eebde0d..1b2c87d3a 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -91,6 +91,11 @@ + + + + + @@ -1228,6 +1233,11 @@ + + + + + @@ -3026,6 +3036,14 @@ + + + + + + + + @@ -5807,6 +5825,11 @@ + + + + + @@ -8190,6 +8213,14 @@ + + + + + + + + diff --git a/shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt b/shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt index 230344ae9..3c08d4996 100644 --- a/shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt +++ b/shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt @@ -50,6 +50,18 @@ class EncryptedTokenManagerImpl( private var profileToken: String? = null private var temporaryScope: TemporaryAuthScope? = null + /** + * Incremented whenever the persistent identity moves: writing or clearing + * persistent credentials, and switching the active server. + * Stamped onto snapshots so a scope captured before a sign-out cannot read or + * overwrite the credentials of the login that replaced it. Overlay begin/end + * deliberately does not move it — see [AuthScopeSnapshot.credentialEpoch]. + */ + // Zero is reserved by AuthScopeSnapshot for callers that did not capture + // a live generation. A manager constructed over credentials already on + // disk must therefore begin at a non-sentinel value. + private var persistentCredentialEpoch: Long = 1L + private val _sessionExpired = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, @@ -71,6 +83,7 @@ class EncryptedTokenManagerImpl( mutex.withLock { if (id != activeServerId) { activeServerId = id + persistentCredentialEpoch += 1 reloadCacheUnsynchronized() } } @@ -102,6 +115,7 @@ class EncryptedTokenManagerImpl( val liveId = registry.activeServerId.value if (liveId != activeServerId) { activeServerId = liveId + persistentCredentialEpoch += 1 reloadCacheUnsynchronized() } } @@ -138,6 +152,7 @@ class EncryptedTokenManagerImpl( this.refreshToken = refreshToken val expiryEpochMs = System.currentTimeMillis() + expiresIn * 1000L this.tokenExpiryEpochMs = expiryEpochMs + persistentCredentialEpoch += 1 prefs.edit() .putString(serverScopedKey(serverId, KEY_ACCESS_TOKEN), accessToken) .putString(serverScopedKey(serverId, KEY_REFRESH_TOKEN), refreshToken) @@ -175,6 +190,7 @@ class EncryptedTokenManagerImpl( } private fun clearPersistentTokensLocked() { + persistentCredentialEpoch += 1 val serverId = activeServerId accessToken = null refreshToken = null @@ -265,6 +281,7 @@ class EncryptedTokenManagerImpl( mutex.withLock { if (activeServerId == serverId) return@withLock activeServerId = serverId + persistentCredentialEpoch += 1 reloadCacheUnsynchronized() } } @@ -305,6 +322,7 @@ class EncryptedTokenManagerImpl( serverUrl = scope.serverUrl, profileToken = scope.profileToken, credentialGenerationId = scope.generationId, + identityGeneration = identityTransitions.generation.value, ) } val serverId = activeServerId ?: return@withLock null @@ -320,6 +338,8 @@ class EncryptedTokenManagerImpl( profileId = profileId, serverUrl = url, profileToken = profileToken, + identityGeneration = identityTransitions.generation.value, + credentialEpoch = persistentCredentialEpoch, ) } @@ -333,9 +353,27 @@ class EncryptedTokenManagerImpl( else prefs.getString(serverScopedKey(serverId, KEY_REFRESH_TOKEN), null) } + /** + * True when an identity transition has happened since [this] was captured. + * + * The persistent path is keyed by serverId alone, so a snapshot taken before + * a sign-out could still read — and overwrite — the credentials issued by the + * NEXT login on that same server. Comparing the captured identity generation + * closes that: [saveTokens] and [clearTokens]/[invalidateSession] both run + * inside `identityTransitions.changing`, which increments it. + * + * `0L` means "not captured from a live snapshot" — several call sites build a + * scope by hand (the interceptor's refresh fallback, companion pairing, + * remote-playback identity) and carry the default. Those keep the old + * behaviour rather than failing closed on a generation they never recorded. + */ + private fun AuthScopeSnapshot.credentialsReplaced(): Boolean = + credentialEpoch != 0L && credentialEpoch != persistentCredentialEpoch + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = mutex.withLock { val generationId = scope.credentialGenerationId if (generationId == null) { + if (scope.credentialsReplaced()) return@withLock null persistentAccessToken(scope.serverId) } else { temporaryScope @@ -347,6 +385,7 @@ class EncryptedTokenManagerImpl( override suspend fun getRefreshTokenForScope(scope: AuthScopeSnapshot): String? = mutex.withLock { val generationId = scope.credentialGenerationId if (generationId == null) { + if (scope.credentialsReplaced()) return@withLock null persistentRefreshToken(scope.serverId) } else { temporaryScope @@ -377,6 +416,9 @@ class EncryptedTokenManagerImpl( val expiryEpochMs = System.currentTimeMillis() + expiresIn * 1000L val generationId = scope.credentialGenerationId if (generationId == null) { + // A stale scope must not overwrite the credentials of the login + // that replaced it. + if (scope.credentialsReplaced()) return@withLock savePersistentTokens(scope.serverId, accessToken, refreshToken, expiryEpochMs) return@withLock } @@ -451,4 +493,55 @@ class EncryptedTokenManagerImpl( // strip the pre-multi-server unprefixed key off disk. const val KEY_SERVER_URL = "server_url" } + + private fun currentScopeMatchesLocked( + scope: AuthScopeSnapshot, + expectedGeneration: Long, + ): Boolean { + if (identityTransitions.generation.value != expectedGeneration) return false + val temporary = temporaryScope + val generationId = scope.credentialGenerationId + if (generationId != null) { + return temporary?.generationId == generationId && + temporary.serverId == scope.serverId && + temporary.serverUrl == scope.serverUrl + } + if (temporary != null || activeServerId != scope.serverId) return false + return registry.entries.value + .firstOrNull { it.id == scope.serverId } + ?.url == scope.serverUrl + } + + override suspend fun invalidateSessionForScope(scope: AuthScopeSnapshot): Boolean = + tokenWriteMutex.withLock { + val matchesBeforeTransition = mutex.withLock { + ensureCacheMatchesRegistryLocked() + currentScopeMatchesLocked( + scope = scope, + expectedGeneration = scope.identityGeneration, + ) + } + if (!matchesBeforeTransition) return@withLock false + + identityTransitions.changing(IdentityTransitionKind.SIGN_OUT) { + mutex.withLock { + ensureCacheMatchesRegistryLocked() + // `changing` increments the generation before entering this + // block. Any intervening identity transition therefore + // makes this value larger and the mutation fails closed. + if ( + !currentScopeMatchesLocked( + scope = scope, + expectedGeneration = scope.identityGeneration + 1, + ) + ) { + return@withLock false + } + val wasTemporary = temporaryScope != null + clearCurrentScopeLocked() + if (!wasTemporary) _sessionExpired.tryEmit(Unit) + true + } + } + } } diff --git a/shared/src/androidUnitTest/kotlin/org/siloserver/silo/network/EncryptedTokenManagerScopeGenerationTest.kt b/shared/src/androidUnitTest/kotlin/org/siloserver/silo/network/EncryptedTokenManagerScopeGenerationTest.kt new file mode 100644 index 000000000..f927b4e17 --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/org/siloserver/silo/network/EncryptedTokenManagerScopeGenerationTest.kt @@ -0,0 +1,150 @@ +package org.siloserver.silo.network + +import android.content.SharedPreferences +import java.lang.reflect.Proxy +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.server.ServerEntry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class EncryptedTokenManagerScopeGenerationTest { + + @Test + fun staleSameServerScopeCannotReadOrRestoreReloggedCredentials() = runTest { + val registry = FakeServerRegistry() + val manager = EncryptedTokenManagerImpl( + prefs = inMemoryPreferences(), + registry = registry, + ) + manager.saveTokens("old-access", "old-refresh", 3600) + val staleScope = checkNotNull(manager.snapshotCurrentScope()) + + manager.clearTokens() + manager.saveTokens("new-access", "new-refresh", 3600) + + assertNull(manager.getAccessTokenForScope(staleScope)) + assertNull(manager.getRefreshTokenForScope(staleScope)) + + manager.saveTokensForScope(staleScope, "stale-access", "stale-refresh", 3600) + + assertEquals("new-access", manager.getAccessToken()) + assertEquals("new-refresh", manager.getRefreshToken()) + } + + @Test + fun startupSnapshotOfPreloadedCredentialsCannotReadOrRestoreReloggedCredentials() = runTest { + val registry = FakeServerRegistry() + val manager = EncryptedTokenManagerImpl( + prefs = inMemoryPreferences( + AndroidServerRegistry.serverScopedKey("server-a", EncryptedTokenManagerImpl.KEY_ACCESS_TOKEN) to + "old-access", + AndroidServerRegistry.serverScopedKey("server-a", EncryptedTokenManagerImpl.KEY_REFRESH_TOKEN) to + "old-refresh", + ), + registry = registry, + ) + val staleScope = checkNotNull(manager.snapshotCurrentScope()) + + manager.clearTokens() + manager.saveTokens("new-access", "new-refresh", 3600) + + assertNull(manager.getAccessTokenForScope(staleScope)) + assertNull(manager.getRefreshTokenForScope(staleScope)) + manager.saveTokensForScope(staleScope, "stale-access", "stale-refresh", 3600) + assertEquals("new-access", manager.getAccessToken()) + assertEquals("new-refresh", manager.getRefreshToken()) + } + + @Test + fun registryFirstSwitchInvalidatesSnapshotBeforeManagerSwitchCall() = runTest { + val registry = FakeServerRegistry() + val manager = EncryptedTokenManagerImpl( + prefs = inMemoryPreferences(), + registry = registry, + ) + manager.saveTokens("server-a-access", "server-a-refresh", 3600) + val staleScope = checkNotNull(manager.snapshotCurrentScope()) + + registry.switchExternally("server-b") + manager.getAccessToken() + manager.switchActiveServer("server-b") + + assertNull(manager.getAccessTokenForScope(staleScope)) + manager.saveTokensForScope(staleScope, "stale-access", "stale-refresh", 3600) + assertNull(manager.getAccessTokenForScope(staleScope)) + } + + private class FakeServerRegistry : ServerRegistry { + private val serverA = ServerEntry(id = "server-a", url = "https://server-a.example") + private val serverB = ServerEntry(id = "server-b", url = "https://server-b.example") + private val entriesFlow = MutableStateFlow(listOf(serverA, serverB)) + private val activeServerIdFlow = MutableStateFlow(serverA.id) + private val activeEntryFlow = MutableStateFlow(serverA) + override val entries: StateFlow> = entriesFlow + override val activeServerId: StateFlow = activeServerIdFlow + override val activeEntry: StateFlow = activeEntryFlow + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = serverA.id + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + + fun switchExternally(serverId: String) { + activeServerIdFlow.value = serverId + activeEntryFlow.value = entriesFlow.value.first { it.id == serverId } + } + } + + private fun inMemoryPreferences(vararg initialValues: Pair): SharedPreferences { + val values = mutableMapOf(*initialValues) + lateinit var preferences: SharedPreferences + preferences = Proxy.newProxyInstance( + SharedPreferences::class.java.classLoader, + arrayOf(SharedPreferences::class.java), + ) { _, method, args -> + when (method.name) { + "getString" -> values[args!![0]] as? String ?: args[1] + "getLong" -> values[args!![0]] as? Long ?: args[1] + "contains" -> values.containsKey(args!![0]) + "getAll" -> values.toMap() + "edit" -> editor(values) + "registerOnSharedPreferenceChangeListener", + "unregisterOnSharedPreferenceChangeListener" -> Unit + else -> method.defaultValue() + } + } as SharedPreferences + return preferences + } + + private fun editor(values: MutableMap): SharedPreferences.Editor { + lateinit var editor: SharedPreferences.Editor + editor = Proxy.newProxyInstance( + SharedPreferences.Editor::class.java.classLoader, + arrayOf(SharedPreferences.Editor::class.java), + ) { _, method, args -> + when (method.name) { + "putString", "putLong" -> editor.also { values[args!![0] as String] = args[1] } + "remove" -> editor.also { values.remove(args!![0] as String) } + "clear" -> editor.also { values.clear() } + "apply" -> Unit + "commit" -> true + else -> editor + } + } as SharedPreferences.Editor + return editor + } + + private fun java.lang.reflect.Method.defaultValue(): Any? = when (returnType) { + java.lang.Boolean.TYPE -> false + java.lang.Integer.TYPE -> 0 + java.lang.Long.TYPE -> 0L + java.lang.Float.TYPE -> 0f + else -> null + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt index dce9edabc..e6767eaf6 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt @@ -11,7 +11,7 @@ import org.koin.dsl.module val networkModule = module { single { DefaultIdentityTransitionBarrier() } single { TokenManagerImpl(get()) } - single { createSiloClient(get(), getOrNull(), getOrNull()) } + single { createSiloClient(get(), getOrNull(), getOrNull(), getOrNull()) } single { AuthApi(get()) } single { DefaultDeviceLoginApi(get()) } single { CatalogApi(get()) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthInterceptorImpl.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthInterceptorImpl.kt index 5a61c0432..94e5bf01a 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthInterceptorImpl.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthInterceptorImpl.kt @@ -6,6 +6,8 @@ import io.ktor.client.plugins.api.* import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.http.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.siloserver.silo.model.auth.RefreshRequest @@ -21,6 +23,7 @@ class SiloAuthConfig { var tokenManager: TokenManager? = null var deviceMetadataProvider: DeviceMetadataProvider? = null var diagnosticsObserver: NetworkDiagnosticsObserver? = null + var cleartextOriginConsent: CleartextOriginConsent? = null } /** @@ -43,31 +46,66 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { ?: error("TokenManager must be provided to SiloAuthPlugin") val deviceMetadataProvider = pluginConfig.deviceMetadataProvider val diagnosticsObserver = pluginConfig.diagnosticsObserver + val cleartextOriginConsent = pluginConfig.cleartextOriginConsent val refreshMutex = Mutex() + // Temporary credential generations (remote-playback overlays) whose refresh + // the server has definitively rejected. Such an overlay is deliberately left + // INSTALLED — clearing it would make every later token read fall through to + // the saved owner's account, so the guest session would silently continue as + // the owner. Flagging the generation here is what stops the plugin from + // refreshing dead credentials again on every subsequent 401. + val deadCredentialGenerations = MutableStateFlow>(emptySet()) + onRequest { request, _ -> val skipAuth = request.attributes.getOrNull(SkipSiloAuthAttributeKey) == true val diagnosticsScope = request.attributes.getOrNull(DiagnosticsRequestScopeKey) + val pinned = request.attributes.getOrNull(AuthScopeAttributeKey) + val activeServerIdBefore = if (pinned == null) tokenManager.getCurrentServerId() else null + val trustedServerUrl = pinned?.serverUrl ?: tokenManager.getServerUrl() + + // Shared calls are normally relative. Resolve those against the exact + // server that owns the credential scope before deciding whether any + // Silo header may be attached. + if ( + request.url.encodedPath.startsWith("/api/") && + (request.url.host.isBlank() || request.url.host == "localhost") && + trustedServerUrl.isNotBlank() + ) { + request.url.rebaseRelativeApiUrl(trustedServerUrl) + } + + if ( + // skipSiloAuth is also used by login/refresh/device-login POSTs: + // those requests omit headers but still carry credentials in the + // body. Only read-only candidate probes may bypass consent. + (!skipAuth || request.method != HttpMethod.Get) && + cleartextOriginConsent?.requiresApproval(request.url.toString()) == true + ) { + request.removeSiloCredentialHeaders() + throw CleartextOriginNotApprovedException(request.url.toString()) + } + + val sameOrigin = isSameSiloHttpOrigin(trustedServerUrl, request.url) + if (skipAuth) { + request.removeSiloCredentialHeaders() + if (sameOrigin) { + request.attachSiloDeviceMetadataHeaders(deviceMetadataProvider) + } + return@onRequest + } + + if (!sameOrigin) { + request.removeSiloCredentialHeaders() + return@onRequest + } // Pinned (Track B outbox replay): bind this request to a captured scope // regardless of the globally-active server/profile, so a mid-drain switch // can't send it to the wrong account. Uses the snapshot's URL/profile and // the *live* per-server access token (handles rotation). - val pinned = request.attributes.getOrNull(AuthScopeAttributeKey) - if (pinned != null && !skipAuth) { - if (request.url.encodedPath.startsWith("/api/") && pinned.serverUrl.isNotBlank()) { - val originalPath = request.url.encodedPath - val originalParameters = request.url.parameters.build() - val originalFragment = request.url.fragment - val originalProtocol = request.url.protocol - request.url.takeFrom(pinned.serverUrl) - request.url.restoreWebSocketProtocol(originalProtocol) - request.url.encodedPath = originalPath - request.url.parameters.clear() - request.url.parameters.appendAll(originalParameters) - request.url.fragment = originalFragment - } + if (pinned != null) { // Replace (never append) the scoped headers; clear the profile token // header when the snapshot has none. request.headers.remove(HttpHeaders.Authorization) @@ -85,65 +123,83 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { return@onRequest } - // Shared API calls use relative paths; bind them to the configured server URL - // before the request is sent so Ktor doesn't fall back to localhost on iOS. - if (request.url.encodedPath.startsWith("/api/") && request.url.host == "localhost") { - val serverUrl = tokenManager.getServerUrl() - if (serverUrl.isNotBlank()) { - val originalPath = request.url.encodedPath - val originalParameters = request.url.parameters.build() - val originalFragment = request.url.fragment - val originalProtocol = request.url.protocol - - request.url.takeFrom(serverUrl) - request.url.restoreWebSocketProtocol(originalProtocol) - request.url.encodedPath = originalPath - request.url.parameters.clear() - request.url.parameters.appendAll(originalParameters) - request.url.fragment = originalFragment - } - } - - if (skipAuth) { - request.headers.remove(HttpHeaders.Authorization) - request.headers.remove("X-Profile-Id") - request.headers.remove("X-Profile-Token") - request.attachSiloDeviceMetadataHeaders(deviceMetadataProvider) - return@onRequest - } - // Skip auth headers for the refresh endpoint itself to avoid recursion val isRefreshRequest = request.url.encodedPath.endsWith("/auth/refresh") if (isRefreshRequest) return@onRequest - tokenManager.getAccessToken()?.let { token -> + val accessToken = tokenManager.getAccessToken() + val profileId = tokenManager.getProfileId() + val profileToken = tokenManager.getProfileToken() + val activeServerIdAfter = tokenManager.getCurrentServerId() + val activeServerUrlAfter = tokenManager.getServerUrl() + if ( + activeServerIdBefore != activeServerIdAfter || + !isSameHttpOrigin(trustedServerUrl, activeServerUrlAfter) + ) { + request.removeSiloCredentialHeaders() + return@onRequest + } + + accessToken?.let { token -> request.header(HttpHeaders.Authorization, "Bearer $token") } request.applyProfileHeaders( diagnosticsScope = diagnosticsScope, - activeProfileId = tokenManager.getProfileId(), - activeProfileToken = tokenManager.getProfileToken(), + activeProfileId = profileId, + activeProfileToken = profileToken, ) request.attachSiloDeviceMetadataHeaders(deviceMetadataProvider) } on(Send) { request -> - if (request.attributes.getOrNull(SkipSiloAuthAttributeKey) == true) { - return@on proceed(request) - } - // Pinned scope (Track B): refresh against the *captured* scope, never the // active one, and never invalidate the active UI session — a failed // pinned refresh just surfaces the 401 so the outbox keeps the op. val pinnedScope = request.attributes.getOrNull(AuthScopeAttributeKey) + val normalScope = + if (pinnedScope == null) tokenManager.snapshotCurrentScope() else null + val activeServerIdBeforeUrl = + if (pinnedScope == null) normalScope?.serverId ?: tokenManager.getCurrentServerId() else null + val trustedServerUrl = + pinnedScope?.serverUrl ?: normalScope?.serverUrl ?: tokenManager.getServerUrl() + val activeServerIdBeforeRequest = + if (pinnedScope == null) tokenManager.getCurrentServerId() else null + if ( + pinnedScope == null && + activeServerIdBeforeUrl != activeServerIdBeforeRequest + ) { + request.removeSiloCredentialHeaders() + return@on proceed(request) + } + if (request.attributes.getOrNull(SkipSiloAuthAttributeKey) == true) { + if (!isSameSiloHttpOrigin(trustedServerUrl, request.url)) { + request.removeSiloCredentialHeaders() + } + return@on proceed(request) + } + if (!isSameSiloHttpOrigin(trustedServerUrl, request.url)) { + request.removeSiloCredentialHeaders() + return@on proceed(request) + } if (pinnedScope != null) { val sentAuth = request.headers[HttpHeaders.Authorization] val originalCall = proceed(request) if (originalCall.response.status != HttpStatusCode.Unauthorized) { return@on originalCall } + val pinnedGeneration = pinnedScope.credentialGenerationId + if (pinnedGeneration != null && pinnedGeneration in deadCredentialGenerations.value) { + // Already-rejected temporary credentials: surface the 401 instead of + // re-refreshing them for every pinned op (progress ticks, teardown). + return@on originalCall + } + // A redirect can carry the pinned call off the Silo origin; refreshing + // then would hand this scope's credentials to whatever answered. + if (!isSameSiloHttpOrigin(pinnedScope.serverUrl, originalCall.request.url)) { + return@on originalCall + } diagnosticsObserver.safeAuthRefresh("required") val refreshed = refreshMutex.withLock { // Another path may have refreshed this scope while we waited. @@ -179,6 +235,11 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { after != null && after != sentAuth } else { diagnosticsObserver.safeAuthRefresh("failed") + if (pinnedGeneration != null && + refreshResponse.status.shouldInvalidateSessionAfterRefreshFailure() + ) { + deadCredentialGenerations.update { it + pinnedGeneration } + } // Don't invalidate the active session for a background scope. // Re-check in case a concurrent path refreshed it in flight. val after = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } @@ -200,11 +261,19 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { } } - // Capture the access token we will actually SEND with this request. - // If the response comes back 401, we compare against this snapshot + val refreshScope = normalScope ?: AuthScopeSnapshot( + serverId = activeServerIdBeforeRequest.orEmpty(), + profileId = null, + serverUrl = trustedServerUrl, + profileToken = null, + ) + + // Capture the authorization value we will actually SEND, together with + // the server identity and origin that own it. If the response comes back + // 401, we compare against this snapshot // inside the refresh mutex to detect a concurrent refresh that // already happened — so N parallel 401s collapse into ONE refresh. - val tokenBeforeRequest = tokenManager.getAccessToken() + val authorizationBeforeRequest = request.headers[HttpHeaders.Authorization] val originalCall = proceed(request) @@ -212,6 +281,15 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { if (originalCall.response.status != HttpStatusCode.Unauthorized) { return@on originalCall } + if (!isSameSiloHttpOrigin(trustedServerUrl, originalCall.request.url)) { + return@on originalCall + } + if ( + tokenManager.getCurrentServerId() != activeServerIdBeforeRequest || + !isSameHttpOrigin(trustedServerUrl, tokenManager.getServerUrl()) + ) { + return@on originalCall + } diagnosticsObserver.safeAuthRefresh("required") val requestPath = originalCall.request.url.encodedPath @@ -219,6 +297,18 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { return@on originalCall } + // Identity of the temporary overlay (remote playback) this request ran + // under, if any. Null means the request ran on a saved account. + val temporaryGeneration = tokenManager.temporaryGenerationId() + if (temporaryGeneration != null && + temporaryGeneration in deadCredentialGenerations.value + ) { + // The server already rejected these credentials. Refreshing again would + // storm it once per request for the rest of the handoff; the overlay stays + // installed so the guest cannot fall back onto the owner's account. + return@on originalCall + } + // Capture the server id as well so we can detect a mid-refresh server // switch — without this, a 401-refresh kicked off against server A // could land after the user has switched to server B and write A's @@ -236,30 +326,50 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { // "refresh" — the refresh token wouldn't be valid for the new // server anyway, and we'd risk persisting cross-server tokens. val serverIdNow = tokenManager.getCurrentServerId() - if (serverIdNow != serverIdBeforeRequest) { + val serverUrlNow = tokenManager.getServerUrl() + if ( + serverIdNow != activeServerIdBeforeRequest || + !isSameHttpOrigin(trustedServerUrl, serverUrlNow) + ) { return@withLock false } - val tokenNow = tokenManager.getAccessToken() - if (tokenNow != null && tokenNow != tokenBeforeRequest) { + val tokenNow = tokenManager.getAccessTokenForScope(refreshScope) + if ( + tokenManager.getCurrentServerId() != activeServerIdBeforeRequest || + !isSameHttpOrigin(trustedServerUrl, tokenManager.getServerUrl()) + ) { + return@withLock false + } + if (tokenNow != null && "Bearer $tokenNow" != authorizationBeforeRequest) { // Another coroutine already refreshed while we were waiting — // just retry the original request with the new token. return@withLock true } - val refreshToken = tokenManager.getRefreshToken() + if (temporaryGeneration != null && + temporaryGeneration in deadCredentialGenerations.value + ) { + // A 401 that won the race already proved these temporary credentials + // are dead; the token is unchanged, so without this every waiter would + // repeat the same doomed refresh. + return@withLock false + } + + // Scope-bound, not global: a refresh must spend the token belonging to + // the scope this request ran under. + val refreshToken = tokenManager.getRefreshTokenForScope(refreshScope) if (refreshToken.isNullOrBlank()) { return@withLock false } try { diagnosticsObserver.safeAuthRefresh("started") - val serverUrl = tokenManager.getServerUrl() - if (serverUrl.isBlank()) { + if (trustedServerUrl.isBlank()) { return@withLock false } - val refreshResponse = client.post("$serverUrl/api/v1/auth/refresh") { + val refreshResponse = client.post("$trustedServerUrl/api/v1/auth/refresh") { contentType(ContentType.Application.Json) setBody(RefreshRequest(refreshToken)) } @@ -270,7 +380,11 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { // save time, so a mismatch here means we'd write to the wrong // slot. val serverIdAfterCall = tokenManager.getCurrentServerId() - if (serverIdAfterCall != serverIdBeforeRequest) { + val serverUrlAfterCall = tokenManager.getServerUrl() + if ( + serverIdAfterCall != activeServerIdBeforeRequest || + !isSameHttpOrigin(trustedServerUrl, serverUrlAfterCall) + ) { return@withLock false } @@ -280,32 +394,53 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { // start a refresh with the still-valid refresh token; without // this guard the refresh response lands after clearTokens() // and saveTokens() silently signs the user back in. - if (tokenManager.getRefreshToken().isNullOrBlank()) { + if (tokenManager.getRefreshTokenForScope(refreshScope).isNullOrBlank()) { return@withLock false } if (refreshResponse.status.isSuccess()) { diagnosticsObserver.safeAuthRefresh("succeeded") val tokens = refreshResponse.body() - tokenManager.saveTokens( + tokenManager.saveTokensForScope( + scope = refreshScope, accessToken = tokens.accessToken, refreshToken = tokens.refreshToken, - expiresIn = tokens.expiresIn + expiresIn = tokens.expiresIn, ) - true + val after = tokenManager.getAccessTokenForScope(refreshScope) + after != null && "Bearer $after" != authorizationBeforeRequest } else { diagnosticsObserver.safeAuthRefresh("failed") // Only auth rejection proves the refresh token is bad. // Gateway/proxy/server failures should keep the session so // a temporary outage does not sign the user out. if (refreshResponse.status.shouldInvalidateSessionAfterRefreshFailure()) { - // The [TokenManager.sessionExpired] event emitted by - // this call is what the root NavHost observer uses to - // route the user back to the login screen; without it, - // the UI would stay on Home and keep rendering - // "Failed to load..." for every subsequent API call - // that now has no credentials. - tokenManager.invalidateSession() + val generationNow = tokenManager.temporaryGenerationId() + when { + // The identity changed while the refresh was in flight + // (overlay began or ended): the rejection belongs to a + // credential set that is no longer installed, so it must + // not tear down whatever is installed now. + generationNow != temporaryGeneration -> Unit + + // Remote playback: the rejected credentials are a + // temporary overlay. invalidateSession() would drop that + // overlay, and every later read would fall through to the + // saved OWNER's account — the guest would keep browsing + // and writing history as the owner. Flag the generation + // dead and leave the overlay installed instead; the cast + // teardown path is what removes it. + temporaryGeneration != null -> + deadCredentialGenerations.update { it + temporaryGeneration } + + // The [TokenManager.sessionExpired] event emitted by + // this call is what the root NavHost observer uses to + // route the user back to the login screen; without it, + // the UI would stay on Home and keep rendering + // "Failed to load..." for every subsequent API call + // that now has no credentials. + else -> tokenManager.invalidateSessionForScope(refreshScope) + } } false } @@ -322,11 +457,23 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { // NOT run a second time, so if we don't update the header here the // retry gets sent with the expired Bearer token and the server // returns another 401. - val newAccessToken = tokenManager.getAccessToken() - if (newAccessToken != null) { - request.headers.remove(HttpHeaders.Authorization) - request.header(HttpHeaders.Authorization, "Bearer $newAccessToken") + val retryServerIdBeforeToken = tokenManager.getCurrentServerId() + val retryServerUrlBeforeToken = tokenManager.getServerUrl() + val newAccessToken = tokenManager.getAccessTokenForScope(refreshScope) + val retryServerIdAfterToken = tokenManager.getCurrentServerId() + val retryServerUrlAfterToken = tokenManager.getServerUrl() + if ( + retryServerIdBeforeToken != activeServerIdBeforeRequest || + retryServerIdAfterToken != activeServerIdBeforeRequest || + !isSameHttpOrigin(trustedServerUrl, retryServerUrlBeforeToken) || + !isSameHttpOrigin(trustedServerUrl, retryServerUrlAfterToken) || + !isSameSiloHttpOrigin(trustedServerUrl, request.url) || + newAccessToken == null + ) { + return@on originalCall } + request.headers.remove(HttpHeaders.Authorization) + request.header(HttpHeaders.Authorization, "Bearer $newAccessToken") proceed(request) } else { originalCall @@ -361,6 +508,14 @@ private fun HttpRequestBuilder.applyProfileHeaders( } } +/** + * Generation id of the temporary credential overlay currently installed (remote + * playback), or null when the active identity is a saved account. Managers that + * don't model overlays report null, which keeps the saved-account behaviour. + */ +private suspend fun TokenManager.temporaryGenerationId(): String? = + snapshotCurrentScope()?.credentialGenerationId + private fun HttpStatusCode.shouldInvalidateSessionAfterRefreshFailure(): Boolean = this == HttpStatusCode.BadRequest || this == HttpStatusCode.Unauthorized || @@ -381,6 +536,50 @@ private suspend fun HttpRequestBuilder.attachSiloDeviceMetadataHeaders( device.clientVersion?.takeIf { it.isNotBlank() }?.let { header("X-Silo-Client-Version", it) } } +private fun URLBuilder.rebaseRelativeApiUrl(serverUrl: String) { + val originalPath = encodedPath + val originalParameters = parameters.build() + val originalFragment = fragment + val originalProtocol = protocol + + takeFrom(serverUrl) + restoreWebSocketProtocol(originalProtocol) + encodedPath = originalPath + parameters.clear() + parameters.appendAll(originalParameters) + fragment = originalFragment +} + +private fun HttpRequestBuilder.removeSiloCredentialHeaders() { + headers.remove(HttpHeaders.Authorization) + headers.remove("X-Profile-Id") + headers.remove("X-Profile-Token") + headers.names() + .filter { name -> name.startsWith("X-Silo-", ignoreCase = true) } + .forEach(headers::remove) +} + +private fun isSameSiloHttpOrigin(serverUrl: String, requestUrl: URLBuilder): Boolean { + val httpRequestUrl = when (requestUrl.protocol) { + URLProtocol.WS -> requestUrl.toString().replaceSchemeForOriginCheck("http") + URLProtocol.WSS -> requestUrl.toString().replaceSchemeForOriginCheck("https") + else -> requestUrl.toString() + } + return isSameHttpOrigin(serverUrl, httpRequestUrl) +} + +private fun isSameSiloHttpOrigin(serverUrl: String, requestUrl: Url): Boolean { + val httpRequestUrl = when (requestUrl.protocol) { + URLProtocol.WS -> requestUrl.toString().replaceSchemeForOriginCheck("http") + URLProtocol.WSS -> requestUrl.toString().replaceSchemeForOriginCheck("https") + else -> requestUrl.toString() + } + return isSameHttpOrigin(serverUrl, httpRequestUrl) +} + +private fun String.replaceSchemeForOriginCheck(scheme: String): String = + "$scheme://${substringAfter("://")}" + /** * Re-applies the websocket protocol after a `takeFrom(serverUrl)` rebase. * diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthScopeSnapshot.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthScopeSnapshot.kt index 8c1aa32dc..f09059e25 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthScopeSnapshot.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthScopeSnapshot.kt @@ -20,6 +20,11 @@ import io.ktor.util.AttributeKey * refresh tokens are read live from the captured credential slot at send time * because they rotate on refresh; freezing their values would break the second * op in a drain after the first triggers a token rotation. + * + * [identityGeneration] changes before every server, account, profile, or + * temporary-scope mutation. It distinguishes a later login that happens to + * reuse the same server/profile identifiers from the credential identity that + * was active when this snapshot was captured. */ data class AuthScopeSnapshot( val serverId: String, @@ -27,6 +32,23 @@ data class AuthScopeSnapshot( val serverUrl: String, val profileToken: String?, val credentialGenerationId: String? = null, + val identityGeneration: Long = 0L, + /** + * Bumped every time this server's PERSISTENT credentials are written or + * cleared — i.e. by sign-in and sign-out, but deliberately NOT by a + * remote-playback overlay beginning or ending. + * + * Persistent scopes are keyed by serverId alone, so without this a snapshot + * captured before a sign-out would still read — and overwrite — whatever the + * next login stored for that same server. [identityGeneration] cannot serve: + * it moves for overlays too, and an overlay must leave a pinned persistent + * scope working. + * + * `0L` means "not stamped from a live snapshot"; hand-built scopes keep the + * pre-existing behaviour rather than failing closed on a value they never + * recorded. + */ + val credentialEpoch: Long = 0L, ) /** Attribute carrying the [AuthScopeSnapshot] that [SiloAuthPlugin] honors. */ diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/CleartextOriginConsent.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/CleartextOriginConsent.kt new file mode 100644 index 000000000..20276c1fe --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/CleartextOriginConsent.kt @@ -0,0 +1,32 @@ +package org.siloserver.silo.network + +/** + * Exact-origin approval boundary for credential-bearing cleartext traffic. + * + * Implementations persist only a digest of the canonical origin. HTTPS never + * requires an approval entry. + */ +interface CleartextOriginConsent { + suspend fun isApproved(origin: String): Boolean +} + +class CleartextOriginNotApprovedException(origin: String) : + IllegalStateException("Cleartext HTTP origin has not been approved: ${canonicalHttpOrigin(origin) ?: ""}") + +suspend fun CleartextOriginConsent.requiresApproval(url: String): Boolean { + val origin = httpOrigin(url) + return when { + origin?.scheme == "https" -> false + origin?.scheme == "http" -> !isApproved(url) + url.trim().startsWith("http:", ignoreCase = true) -> true + else -> false + } +} + +fun canonicalHttpOrigin(raw: String): String? { + val origin = httpOrigin(raw) ?: return null + val host = if (':' in origin.host) "[${origin.host.trim('[', ']')}]" else origin.host + val defaultPort = if (origin.scheme == "https") 443 else 80 + val port = if (origin.port == defaultPort) "" else ":${origin.port}" + return "${origin.scheme}://$host$port" +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/HttpOriginPolicy.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/HttpOriginPolicy.kt new file mode 100644 index 000000000..fbfe6c095 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/HttpOriginPolicy.kt @@ -0,0 +1,146 @@ +package org.siloserver.silo.network + +import io.ktor.http.Url + +data class HttpOrigin( + val scheme: String, + val host: String, + val port: Int, +) + +fun httpOrigin(raw: String): HttpOrigin? = runCatching { + val scheme = raw.httpSchemeOrNull() ?: return null + val authorityHost = raw.authorityHostOrNull(scheme) ?: return null + + val url = Url(raw) + if (url.user != null || url.password != null) return null + if (url.protocol.name.lowercase() != scheme) return null + + val parsedHost = url.host + if (!parsedHost.isValidHttpHost()) return null + if (!authorityHost.equals(parsedHost, ignoreCase = true)) return null + + HttpOrigin( + scheme = scheme, + host = parsedHost.lowercase(), + port = url.port, + ) +}.getOrNull() + +fun isSameHttpOrigin(serverUrl: String, requestUrl: String): Boolean { + val serverOrigin = httpOrigin(serverUrl) ?: return false + val requestOrigin = httpOrigin(requestUrl) ?: return false + return serverOrigin == requestOrigin +} + +private fun String.httpSchemeOrNull(): String? = when { + startsWith("https://", ignoreCase = true) -> "https" + startsWith("http://", ignoreCase = true) -> "http" + else -> null +} + +private fun String.authorityHostOrNull(scheme: String): String? { + val prefix = "$scheme://" + val authority = drop(prefix.length).takeWhile { it != '/' && it != '?' && it != '#' } + if (authority.isBlank() || authority.any(Char::isUnsafeAuthorityCharacter)) return null + if ('@' in authority) return null + + return if (authority.startsWith('[')) { + authority.bracketedIpv6HostOrNull() + } else { + authority.unbracketedHostOrNull() + } +} + +private fun String.bracketedIpv6HostOrNull(): String? { + val closingBracket = indexOf(']') + if (closingBracket <= 1) return null + if (indexOf('[', startIndex = 1) >= 0) return null + if (indexOf(']', startIndex = closingBracket + 1) >= 0) return null + + val literal = substring(1, closingBracket) + if (!literal.isValidIpv6Literal()) return null + + val suffix = substring(closingBracket + 1) + if (suffix.isNotEmpty()) { + if (!suffix.startsWith(':')) return null + val port = suffix.drop(1) + if (port.isEmpty() || !port.all(Char::isAsciiDigit)) return null + } + return substring(0, closingBracket + 1) +} + +private fun String.unbracketedHostOrNull(): String? { + if ('[' in this || ']' in this) return null + + val firstColon = indexOf(':') + val lastColon = lastIndexOf(':') + if (firstColon != lastColon) return null + + val host = if (lastColon >= 0) substring(0, lastColon) else this + if (!host.isValidRawHttpHost()) return null + + if (lastColon >= 0) { + val port = substring(lastColon + 1) + if (port.isEmpty() || !port.all(Char::isAsciiDigit)) return null + } + return host +} + +private fun String.isValidRawHttpHost(): Boolean = + isNotBlank() && + !startsWith('.') && + !contains("..") && + all { character -> + character.isLetterOrDigit() || + character == '.' || + character == '-' || + character == '_' + } + +private fun String.isValidIpv6Literal(): Boolean { + if (isEmpty() || any { !it.isAsciiHexDigit() && it != ':' }) return false + + val compressionIndex = indexOf("::") + if (compressionIndex != lastIndexOf("::")) return false + + if (compressionIndex < 0) { + val groups = split(':') + return groups.size == IPV6_GROUP_COUNT && groups.all(String::isValidIpv6Group) + } + + val leftGroups = substring(0, compressionIndex).ipv6GroupsOrNull() ?: return false + val rightGroups = substring(compressionIndex + 2).ipv6GroupsOrNull() ?: return false + return leftGroups.size + rightGroups.size < IPV6_GROUP_COUNT +} + +private fun String.ipv6GroupsOrNull(): List? { + if (isEmpty()) return emptyList() + return split(':').takeIf { groups -> groups.all(String::isValidIpv6Group) } +} + +private fun String.isValidIpv6Group(): Boolean = + length in 1..IPV6_GROUP_HEX_LENGTH && all(Char::isAsciiHexDigit) + +private fun Char.isUnsafeAuthorityCharacter(): Boolean = + isWhitespace() || + code <= 0x1f || + code == 0x7f || + this == '\\' + +private fun Char.isAsciiDigit(): Boolean = this in '0'..'9' + +private fun Char.isAsciiHexDigit(): Boolean = + isAsciiDigit() || this in 'a'..'f' || this in 'A'..'F' + +private fun String.isValidHttpHost(): Boolean = + isNotBlank() && + none { character -> + character.isWhitespace() || + character.code <= 0x1f || + character.code == 0x7f || + character in "/?#@\\" + } + +private const val IPV6_GROUP_COUNT = 8 +private const val IPV6_GROUP_HEX_LENGTH = 4 diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClientImpl.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClientImpl.kt index a1176627d..71af54159 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClientImpl.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClientImpl.kt @@ -39,6 +39,7 @@ fun createSiloClient( tokenManager: TokenManager, deviceMetadataProvider: DeviceMetadataProvider? = null, diagnosticsObserver: NetworkDiagnosticsObserver? = null, + cleartextOriginConsent: CleartextOriginConsent? = null, ): HttpClient { val platformClient = createPlatformHttpClient() @@ -76,6 +77,7 @@ fun createSiloClient( this.tokenManager = tokenManager this.deviceMetadataProvider = deviceMetadataProvider this.diagnosticsObserver = diagnosticsObserver + this.cleartextOriginConsent = cleartextOriginConsent } install(HttpTimeout) { diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManager.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManager.kt index 17226a6a6..ed8de9456 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManager.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManager.kt @@ -43,6 +43,22 @@ interface TokenManager { */ suspend fun invalidateSession() + /** + * Invalidates [scope] without ever clearing a different active identity. + * + * Single-scope implementations may use this default. Implementations that + * support server switching must override it with an atomic scope check and + * mutation. + * + * @return true only when the captured scope was invalidated. + */ + suspend fun invalidateSessionForScope(scope: AuthScopeSnapshot): Boolean { + val current = snapshotCurrentScope() + if (current != null && current != scope) return false + invalidateSession() + return true + } + /** * Emits [Unit] each time [invalidateSession] runs. Does NOT fire for * plain [clearTokens] calls — manual signout flows own their own nav. diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/HttpOriginPolicyTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/HttpOriginPolicyTest.kt new file mode 100644 index 000000000..d8fb7b96d --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/HttpOriginPolicyTest.kt @@ -0,0 +1,161 @@ +package org.siloserver.silo.network + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class HttpOriginPolicyTest { + @Test + fun cleartextApprovalUsesCanonicalExactOriginAndHttpsNeedsNoApproval() = runTest { + val approved = setOf("http://silo.example", "http://[2001:db8::1]:8090") + val consent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = + canonicalHttpOrigin(origin) in approved + } + + assertFalse(consent.requiresApproval("HTTP://SILO.EXAMPLE:80/path")) + assertFalse(consent.requiresApproval("http://[2001:db8::1]:8090/path")) + assertTrue(consent.requiresApproval("http://silo.example:8090/path")) + assertTrue(consent.requiresApproval("http://user:secret@silo.example/path")) + assertTrue(consent.requiresApproval("""http:\\silo.example\private""")) + assertTrue(consent.requiresApproval("HtTp:/silo.example/private")) + assertFalse(consent.requiresApproval("https://silo.example")) + } + + @Test + fun defaultPortsAndCaseNormalize() { + assertTrue(isSameHttpOrigin("HTTPS://Silo.Example", "https://silo.example:443/a")) + assertTrue(isSameHttpOrigin("http://silo.example", "http://SILO.EXAMPLE:80/a")) + assertEquals( + HttpOrigin(scheme = "https", host = "silo.example", port = 443), + httpOrigin("HTTPS://Silo.Example"), + ) + } + + @Test + fun pathQueryAndFragmentDoNotAffectOrigin() { + assertTrue( + isSameHttpOrigin( + "https://silo.example/base?server=true#settings", + "https://SILO.EXAMPLE/library/items?offset=10#details", + ), + ) + } + + @Test + fun matchingNonDefaultPortsArePreserved() { + assertEquals( + HttpOrigin(scheme = "https", host = "silo.example", port = 8443), + httpOrigin("https://silo.example:8443/a"), + ) + assertTrue( + isSameHttpOrigin( + "https://silo.example:8443", + "https://SILO.EXAMPLE:8443/a", + ), + ) + } + + @Test + fun schemeHostPortAndUserInfoMismatchesFailClosed() { + assertFalse(isSameHttpOrigin("https://silo.example", "http://silo.example/a")) + assertFalse(isSameHttpOrigin("https://silo.example", "https://cdn.silo.example/a")) + assertFalse(isSameHttpOrigin("https://silo.example", "https://silo.example:444/a")) + assertNull(httpOrigin("https://user@silo.example/a")) + assertNull(httpOrigin("file:///tmp/a")) + } + + @Test + fun everyUserInfoFormIsRejected() { + assertNull(httpOrigin("https://user:password@silo.example/a")) + assertNull(httpOrigin("https://:password@silo.example/a")) + assertNull(httpOrigin("https://@silo.example/a")) + assertNull(httpOrigin("https://user:@silo.example/a")) + } + + @Test + fun relativeAndMissingAuthorityUrlsAreRejected() { + listOf( + "", + " ", + "/relative/path", + "silo.example/path", + "https://", + "https:///path", + "https://?query", + "https://#fragment", + "https://:443/path", + ).forEach { raw -> + assertNull(httpOrigin(raw), "Expected a missing authority to be rejected: '$raw'") + } + } + + @Test + fun malformedHostsAndPortsAreRejected() { + assertNull(httpOrigin("https://silo example/path")) + assertNull(httpOrigin("https://silo.example:65536/path")) + assertNull(httpOrigin("https://silo.example:-1/path")) + } + + @Test + fun backslashAuthorityConfusionFailsClosed() { + listOf( + "https://\\attacker.example", + "https://attacker.example\\path", + "https://localhost\\@attacker.example", + "https://attacker.example\u0001.evil", + ).forEach { raw -> + assertNull(httpOrigin(raw), "Expected a confused authority to be rejected: '$raw'") + } + assertFalse( + isSameHttpOrigin( + serverUrl = "https://localhost", + requestUrl = "https://\\attacker.example", + ), + ) + } + + @Test + fun malformedBracketedAuthoritiesFailClosed() { + listOf( + "https://[]/", + "https://[x]/", + "https://[::1/", + "https://[[::1]]/", + "https://[:::1]/", + "https://[::1]extra/", + ).forEach { raw -> + assertNull(httpOrigin(raw), "Expected an invalid bracketed host to be rejected: '$raw'") + } + } + + @Test + fun validatedIpv6AuthoritiesArePreserved() { + assertEquals( + HttpOrigin(scheme = "https", host = "[::1]", port = 443), + httpOrigin("HTTPS://[::1]"), + ) + assertTrue(isSameHttpOrigin("https://[::1]", "https://[::1]:443/a")) + assertTrue( + isSameHttpOrigin( + "http://[2001:db8:0:1::1]:8080", + "http://[2001:DB8:0:1::1]:8080/a", + ), + ) + } + + @Test + fun invalidOriginsNeverCompareEqual() { + assertFalse(isSameHttpOrigin("", "")) + assertFalse(isSameHttpOrigin("/server", "/request")) + assertFalse( + isSameHttpOrigin( + "https://user@silo.example", + "https://user@silo.example/path", + ), + ) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginPinTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginPinTest.kt index 06086402e..9bb9a8a9a 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginPinTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginPinTest.kt @@ -3,13 +3,20 @@ package org.siloserver.silo.network import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf +import io.ktor.http.isSuccess +import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertFailsWith /** * Verifies [SiloAuthPlugin] honors an [AuthScopeSnapshot] pin: a request @@ -18,6 +25,90 @@ import kotlin.test.assertEquals * the network-layer guarantee the Track B outbox drain relies on. */ class SiloAuthPluginPinTest { + @Test + fun unapprovedCleartextOriginIsBlockedBeforeCredentialsReachEngine() = runTest { + var engineCalled = false + val tokenManager = TokenManagerImpl().apply { + setServerUrl("http://silo.lan") + saveTokens("access", "refresh", 3600) + } + val client = HttpClient( + MockEngine { + engineCalled = true + respond("{}", HttpStatusCode.OK) + }, + ) { + install(SiloAuthPlugin) { + this.tokenManager = tokenManager + this.cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + } + } + } + + assertFailsWith { + client.get("/api/v1/items") + } + assertEquals(false, engineCalled) + client.close() + } + + @Test + fun unauthenticatedLoginPostStillRequiresCleartextApproval() = runTest { + var engineCalled = false + val tokenManager = TokenManagerImpl().apply { setServerUrl("http://silo.lan") } + val client = HttpClient( + MockEngine { + engineCalled = true + respond("{}", HttpStatusCode.OK) + }, + ) { + install(SiloAuthPlugin) { + this.tokenManager = tokenManager + this.cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + } + } + } + + assertFailsWith { + client.post("/api/v1/auth/login") { skipSiloAuth() } + } + assertEquals(false, engineCalled) + client.close() + } + + @Test + fun absoluteCandidateAuthPostsCheckTheirOwnCleartextOrigin() = runTest { + var engineCalled = false + val tokenManager = TokenManagerImpl().apply { setServerUrl("https://active.example") } + val client = HttpClient( + MockEngine { + engineCalled = true + respond("{}", HttpStatusCode.OK) + }, + ) { + install(SiloAuthPlugin) { + this.tokenManager = tokenManager + this.cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + } + } + } + + listOf( + "/api/v1/auth/device/start", + "/api/v1/auth/device/poll", + "/api/v1/auth/remote-playback/start", + ).forEach { path -> + assertFailsWith(path) { + client.post("http://candidate.lan$path") { skipSiloAuth() } + } + } + assertEquals(false, engineCalled) + client.close() + } + private class Captured { var url: String = "" @@ -114,4 +205,601 @@ class SiloAuthPluginPinTest { assertEquals("Silo Android TV", captured.siloClient) assertEquals("0.2.3", captured.siloClientVersion) } + + @Test + fun normalRequestsUseCredentialsOnlyOnConfiguredOrigin() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://silo.example:8443") + setProfileId("profile-a") + setProfileToken("profile-token-a") + saveTokens(accessToken = "ACCESS-A", refreshToken = "REFRESH-A", expiresIn = 3600) + } + val captured = mutableListOf() + val client = capturingClient(tokenManager, captured) + + client.get("/api/v1/downloads/download-1/file") + client.get("https://silo.example:8443/api/v1/items") + client.get("https://cdn.silo.example:8443/video") + client.get("https://silo.example:9443/video") + client.get("http://silo.example:8443/video") + + assertEquals( + listOf( + "https://silo.example:8443/api/v1/downloads/download-1/file", + "https://silo.example:8443/api/v1/items", + "https://cdn.silo.example:8443/video", + "https://silo.example:9443/video", + "http://silo.example:8443/video", + ), + captured.map(CapturedRequest::url), + ) + captured.take(2).forEach { request -> + assertEquals("Bearer ACCESS-A", request.authorization) + assertEquals("profile-a", request.profileId) + assertEquals("profile-token-a", request.profileToken) + } + captured.drop(2).forEach { request -> + assertNull(request.authorization, "Authorization leaked to ${request.url}") + assertNull(request.profileId, "X-Profile-Id leaked to ${request.url}") + assertNull(request.profileToken, "X-Profile-Token leaked to ${request.url}") + } + } + + @Test + fun pinnedForeignRequestRemovesSensitiveExplicitHeaders() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://active.example") + saveTokens(accessToken = "ACCESS-A", refreshToken = "REFRESH-A", expiresIn = 3600) + } + val captured = mutableListOf() + val snapshot = AuthScopeSnapshot( + serverId = "server-a", + profileId = "profile-a", + serverUrl = "https://silo.example", + profileToken = "profile-token-a", + ) + + capturingClient(tokenManager, captured).get("https://cdn.example/video") { + authScope(snapshot) + header(HttpHeaders.Authorization, "Signed explicit-cdn-credential") + header("X-Profile-Id", "explicit-profile") + header("X-Profile-Token", "explicit-profile-token") + } + + val request = captured.single() + assertNull(request.authorization) + assertNull(request.profileId) + assertNull(request.profileToken) + } + + @Test + fun foreignUnauthorizedResponseDoesNotRefresh() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + setProfileId("profile-a") + setProfileToken("profile-token-a") + saveTokens(accessToken = "ACCESS-A", refreshToken = "REFRESH-A", expiresIn = 3600) + } + val captured = mutableListOf() + val engine = MockEngine { request -> + captured += request.capture() + respond( + content = "{}", + status = if (request.url.host == "cdn.example") { + HttpStatusCode.Unauthorized + } else { + HttpStatusCode.InternalServerError + }, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val client = HttpClient(engine) { + install(SiloAuthPlugin) { this.tokenManager = tokenManager } + } + + client.get("https://cdn.example/video") + + assertEquals(listOf("https://cdn.example/video"), captured.map(CapturedRequest::url)) + assertNull(captured.single().authorization) + assertNull(captured.single().profileId) + assertNull(captured.single().profileToken) + } + + @Test + fun crossOriginRedirectDoesNotForwardSiloOrExplicitAuthorizationHeaders() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + setProfileId("profile-a") + setProfileToken("profile-token-a") + saveTokens(accessToken = "ACCESS-A", refreshToken = "REFRESH-A", expiresIn = 3600) + } + val captured = mutableListOf() + val provider = object : DeviceMetadataProvider { + override suspend fun current(): SiloDeviceMetadata = + SiloDeviceMetadata( + id = "device-1", + name = "Phone", + platform = "android", + clientName = "Silo Android", + clientVersion = "1.0", + ) + } + val engine = MockEngine { request -> + captured += request.capture() + if (request.url.host == "silo.example") { + respond( + content = "", + status = HttpStatusCode.Found, + headers = headersOf(HttpHeaders.Location, "https://cdn.example/video"), + ) + } else { + respond("ok", HttpStatusCode.OK) + } + } + val client = HttpClient(engine) { + followRedirects = true + install(SiloAuthPlugin) { + this.tokenManager = tokenManager + deviceMetadataProvider = provider + } + } + + client.get("https://silo.example/redirect") { + header(HttpHeaders.Authorization, "Signed explicit-target-credential") + } + + val downstream = captured.single { it.url == "https://cdn.example/video" } + assertNull(downstream.authorization) + assertNull(downstream.profileId) + assertNull(downstream.profileToken) + assertEquals(emptyMap(), downstream.siloHeaders) + } + + @Test + fun activeServerSwitchDuringCredentialReadFailsClosed() = runTest { + val tokenManager = SwitchingTokenManager() + val captured = mutableListOf() + + capturingClient(tokenManager, captured).get("/api/v1/items") + + val request = captured.single() + assertEquals("https://server-a.example/api/v1/items", request.url) + assertNull(request.authorization) + assertNull(request.profileId) + assertNull(request.profileToken) + } + + @Test + fun foreignSkipAuthRequestDoesNotReceiveDeviceMetadata() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + } + val captured = mutableListOf() + val provider = object : DeviceMetadataProvider { + override suspend fun current(): SiloDeviceMetadata = + SiloDeviceMetadata( + id = "device-1", + name = "Phone", + platform = "android", + clientName = "Silo Android", + clientVersion = "1.0", + ) + } + + capturingClient( + tokenManager = tokenManager, + captured = captured, + deviceMetadataProvider = provider, + ).get("https://candidate.example/api/v1/auth/device/start") { + skipSiloAuth() + } + + assertEquals(emptyMap(), captured.single().siloHeaders) + } + + @Test + fun sameOriginSkipAuthRequestKeepsDeviceMetadata() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + } + val captured = mutableListOf() + val provider = deviceMetadataProvider() + + capturingClient( + tokenManager = tokenManager, + captured = captured, + deviceMetadataProvider = provider, + ).get("https://silo.example/api/v1/auth/login") { + skipSiloAuth() + } + + assertEquals("device-1", captured.single().siloHeaders["X-Silo-Device-Id"]) + } + + @Test + fun crossOriginSkipAuthRedirectRemovesCopiedDeviceMetadata() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + } + val captured = mutableListOf() + val engine = MockEngine { request -> + captured += request.capture() + if (request.url.host == "silo.example") { + respond( + content = "", + status = HttpStatusCode.Found, + headers = headersOf(HttpHeaders.Location, "https://candidate.example/device"), + ) + } else { + respond("ok", HttpStatusCode.OK) + } + } + val client = HttpClient(engine) { + followRedirects = true + install(SiloAuthPlugin) { + this.tokenManager = tokenManager + deviceMetadataProvider = deviceMetadataProvider() + } + } + + client.get("https://silo.example/api/v1/auth/login") { + skipSiloAuth() + } + + assertEquals( + emptyMap(), + captured.single { it.url == "https://candidate.example/device" }.siloHeaders, + ) + } + + @Test + fun serverSwitchWhileRequestIsInFlightDoesNotRetryWithNewServerCredentials() = runTest { + val tokenManager = InFlightSwitchingTokenManager() + val captured = mutableListOf() + val engine = MockEngine { request -> + captured += request.capture() + if (captured.size == 1) { + tokenManager.switchToServerB() + respond("{}", HttpStatusCode.Unauthorized) + } else { + respond("{}", HttpStatusCode.OK) + } + } + val client = HttpClient(engine) { + install(SiloAuthPlugin) { this.tokenManager = tokenManager } + } + + client.get("https://server-a.example/api/v1/items") + + assertEquals(1, captured.size) + assertEquals("Bearer server-a-access", captured.single().authorization) + assertEquals(0, tokenManager.invalidations) + } + + @Test + fun normalUnauthorizedRefreshUsesOnlyCapturedScopeOperations() = runTest { + val tokenManager = ScopedRefreshTokenManager() + val paths = mutableListOf() + val client = HttpClient( + MockEngine { request -> + paths += request.url.encodedPath + when { + request.url.encodedPath.endsWith("/auth/refresh") -> + respond( + content = """ + { + "access_token": "server-a-fresh-access", + "refresh_token": "server-a-fresh-refresh", + "expires_in": 3600 + } + """.trimIndent(), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + paths.count { it.endsWith("/catalog/home") } == 1 -> + respond("{}", HttpStatusCode.Unauthorized) + else -> respond("{}", HttpStatusCode.OK) + } + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { this.tokenManager = tokenManager } + } + + client.get("https://server-a.example/api/v1/catalog/home") + + assertEquals( + listOf( + "/api/v1/catalog/home", + "/api/v1/auth/refresh", + "/api/v1/catalog/home", + ), + paths, + ) + assertEquals(0, tokenManager.activeRefreshReads) + assertEquals(0, tokenManager.activeSaves) + assertEquals(2, tokenManager.scopedRefreshReads) + assertEquals(1, tokenManager.scopedSaves) + } + + @Test + fun switchBeforeRefreshTokenReadNeverReadsNewActiveServerToken() = runTest { + val tokenManager = ScopedRefreshTokenManager(switchBeforeRefreshTokenRead = true) + val paths = executeRefreshScenario(tokenManager, HttpStatusCode.OK) + + assertEquals(listOf("/api/v1/catalog/home", "/api/v1/auth/refresh"), paths) + assertEquals(0, tokenManager.activeRefreshReads) + assertEquals(1, tokenManager.scopedRefreshReads) + assertEquals(0, tokenManager.activeSaves) + } + + @Test + fun switchDuringPostResponseCredentialReadSavesOnlyCapturedScope() = runTest { + val tokenManager = ScopedRefreshTokenManager(switchDuringPostResponseRead = true) + executeRefreshScenario(tokenManager, HttpStatusCode.OK) + + assertEquals(0, tokenManager.activeSaves) + assertEquals(1, tokenManager.scopedSaves) + assertEquals("server-a-fresh-access", tokenManager.scopedAccessToken) + } + + @Test + fun switchAtRefreshRejectionInvalidatesOnlyCapturedScope() = runTest { + val tokenManager = ScopedRefreshTokenManager(switchDuringInvalidation = true) + executeRefreshScenario(tokenManager, HttpStatusCode.Unauthorized) + + assertEquals(0, tokenManager.activeInvalidations) + assertEquals(1, tokenManager.scopedInvalidations) + assertEquals("server-a", tokenManager.invalidatedScope?.serverId) + assertEquals("server-b-access", tokenManager.activeAccessToken) + } + + private suspend fun executeRefreshScenario( + tokenManager: ScopedRefreshTokenManager, + refreshStatus: HttpStatusCode, + ): List { + val paths = mutableListOf() + val client = HttpClient( + MockEngine { request -> + paths += request.url.encodedPath + when { + request.url.encodedPath.endsWith("/auth/refresh") -> + respond( + content = if (refreshStatus.isSuccess()) { + """ + { + "access_token": "server-a-fresh-access", + "refresh_token": "server-a-fresh-refresh", + "expires_in": 3600 + } + """.trimIndent() + } else { + """{"error":"unauthorized","message":"expired"}""" + }, + status = refreshStatus, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + paths.count { it.endsWith("/catalog/home") } == 1 -> + respond("{}", HttpStatusCode.Unauthorized) + else -> respond("{}", HttpStatusCode.OK) + } + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { this.tokenManager = tokenManager } + } + client.get("https://server-a.example/api/v1/catalog/home") + return paths + } + + private data class CapturedRequest( + val url: String, + val authorization: String?, + val profileId: String?, + val profileToken: String?, + val siloHeaders: Map, + ) + + private fun io.ktor.client.request.HttpRequestData.capture(): CapturedRequest = + CapturedRequest( + url = url.toString(), + authorization = headers[HttpHeaders.Authorization], + profileId = headers["X-Profile-Id"], + profileToken = headers["X-Profile-Token"], + siloHeaders = headers.entries() + .filter { (name, _) -> name.startsWith("X-Silo-", ignoreCase = true) } + .associate { (name, values) -> name to values.joinToString(",") }, + ) + + private fun capturingClient( + tokenManager: TokenManager, + captured: MutableList, + deviceMetadataProvider: DeviceMetadataProvider? = null, + ): HttpClient = + HttpClient( + MockEngine { request -> + captured += request.capture() + respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }, + ) { + install(SiloAuthPlugin) { + this.tokenManager = tokenManager + this.deviceMetadataProvider = deviceMetadataProvider + } + } + + private fun deviceMetadataProvider(): DeviceMetadataProvider = + object : DeviceMetadataProvider { + override suspend fun current(): SiloDeviceMetadata = + SiloDeviceMetadata( + id = "device-1", + name = "Phone", + platform = "android", + clientName = "Silo Android", + clientVersion = "1.0", + ) + } + + private class SwitchingTokenManager( + private val delegate: TokenManager = TokenManagerImpl(), + ) : TokenManager by delegate { + private var switched = false + private var serverUrlReads = 0 + + override suspend fun getServerUrl(): String { + serverUrlReads += 1 + if (serverUrlReads > 1) switched = false + return if (switched) "https://server-b.example" else "https://server-a.example" + } + + override suspend fun getCurrentServerId(): String = + if (switched) "server-b" else "server-a" + + override suspend fun getAccessToken(): String { + switched = true + return "server-b-access" + } + + override suspend fun getProfileId(): String = "server-b-profile" + + override suspend fun getProfileToken(): String = "server-b-profile-token" + } + + private class InFlightSwitchingTokenManager( + private val delegate: TokenManager = TokenManagerImpl(), + ) : TokenManager by delegate { + private var activeServer = "server-a" + var invalidations = 0 + private set + + fun switchToServerB() { + activeServer = "server-b" + } + + override suspend fun getCurrentServerId(): String = activeServer + + override suspend fun getServerUrl(): String = "https://$activeServer.example" + + override suspend fun getAccessToken(): String = "$activeServer-access" + + override suspend fun getRefreshToken(): String = "$activeServer-refresh" + + override suspend fun getProfileId(): String = "$activeServer-profile" + + override suspend fun getProfileToken(): String = "$activeServer-profile-token" + + override suspend fun invalidateSession() { + invalidations += 1 + } + } + + private class ScopedRefreshTokenManager( + private val switchBeforeRefreshTokenRead: Boolean = false, + private val switchDuringPostResponseRead: Boolean = false, + private val switchDuringInvalidation: Boolean = false, + private val delegate: TokenManager = TokenManagerImpl(), + ) : TokenManager by delegate { + private val scope = AuthScopeSnapshot( + serverId = "server-a", + profileId = null, + serverUrl = "https://server-a.example", + profileToken = null, + identityGeneration = 7L, + ) + private var activeServer = "server-a" + private var accessToken = "server-a-expired-access" + private var armSwitchAfterServerUrlRead = false + private var activeAccessReads = 0 + var activeRefreshReads = 0 + private set + var activeSaves = 0 + private set + var scopedRefreshReads = 0 + private set + var scopedSaves = 0 + private set + var activeInvalidations = 0 + private set + var scopedInvalidations = 0 + private set + var invalidatedScope: AuthScopeSnapshot? = null + private set + val scopedAccessToken: String get() = accessToken + val activeAccessToken: String + get() = if (activeServer == "server-a") accessToken else "server-b-access" + + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = scope + + override suspend fun getCurrentServerId(): String = activeServer + + override suspend fun getServerUrl(): String { + val url = "https://$activeServer.example" + if (armSwitchAfterServerUrlRead) { + armSwitchAfterServerUrlRead = false + activeServer = "server-b" + } + return url + } + + override suspend fun getAccessToken(): String { + activeAccessReads += 1 + if (switchBeforeRefreshTokenRead && activeAccessReads > 1) { + armSwitchAfterServerUrlRead = true + } + return activeAccessToken + } + + override suspend fun getRefreshToken(): String { + activeRefreshReads += 1 + val token = "$activeServer-refresh" + if (switchDuringPostResponseRead && activeRefreshReads == 2) { + activeServer = "server-b" + } + return token + } + + override suspend fun saveTokens( + accessToken: String, + refreshToken: String, + expiresIn: Long, + ) { + activeSaves += 1 + this.accessToken = accessToken + } + + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String { + if (switchBeforeRefreshTokenRead) armSwitchAfterServerUrlRead = true + return accessToken + } + + override suspend fun getRefreshTokenForScope(scope: AuthScopeSnapshot): String { + scopedRefreshReads += 1 + if (switchDuringPostResponseRead && scopedRefreshReads == 2) { + activeServer = "server-b" + } + return "server-a-refresh" + } + + override suspend fun saveTokensForScope( + scope: AuthScopeSnapshot, + accessToken: String, + refreshToken: String, + expiresIn: Long, + ) { + scopedSaves += 1 + this.accessToken = accessToken + } + + override suspend fun invalidateSession() { + if (switchDuringInvalidation) activeServer = "server-b" + activeInvalidations += 1 + } + + override suspend fun invalidateSessionForScope(scope: AuthScopeSnapshot): Boolean { + if (switchDuringInvalidation) activeServer = "server-b" + scopedInvalidations += 1 + invalidatedScope = scope + return true + } + } }