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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android-shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<OkHttpClient>(PLAYER_TRANSPORT_OKHTTP_QUALIFIER) { buildPlayerOkHttpClient() }
single<OkHttpClient>(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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<Preferences>,
) : 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()
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -225,6 +243,29 @@ internal fun mergeSessionAuthHeaders(
}
}

internal fun authenticatedHeadersFor(
serverUrl: String,
requestUrl: String,
sessionHeaders: Map<String, String>,
explicitHeaders: Map<String, String>,
): Map<String, String> {
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
Expand Down Expand Up @@ -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('/')}"
}
Expand All @@ -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
Expand All @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}

Expand All @@ -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()
Loading