diff --git a/app/src/main/java/com/pinakes/app/PinakesApplication.kt b/app/src/main/java/com/pinakes/app/PinakesApplication.kt index e2b3352..51ce419 100644 --- a/app/src/main/java/com/pinakes/app/PinakesApplication.kt +++ b/app/src/main/java/com/pinakes/app/PinakesApplication.kt @@ -8,7 +8,10 @@ import coil.ImageLoader import coil.ImageLoaderFactory import coil.disk.DiskCache import com.pinakes.app.data.sync.CatalogSyncWorker +import com.pinakes.app.data.network.CleartextGuardInterceptor +import com.pinakes.app.data.network.NetworkEntryPoint import dagger.hilt.android.EntryPointAccessors +import okhttp3.OkHttpClient import dagger.hilt.android.HiltAndroidApp import io.sentry.android.core.SentryAndroid import kotlinx.coroutines.CoroutineScope @@ -65,8 +68,18 @@ class PinakesApplication : Application(), ImageLoaderFactory { * headers, so book covers are downloaded once and reused across sessions instead of * being re-fetched on every screen / app open. */ - override fun newImageLoader(): ImageLoader = - ImageLoader.Builder(this) + override fun newImageLoader(): ImageLoader { + // Route Coil through the same cleartext gate as the API client, so a book cover + // served over plain HTTP can't silently downgrade the connection on an HTTPS + // instance (only allowed for loopback or when the user opted into insecure HTTP). + val session = EntryPointAccessors + .fromApplication(this, NetworkEntryPoint::class.java) + .sessionStore() + val guardedClient = OkHttpClient.Builder() + .addInterceptor(CleartextGuardInterceptor { session.allowInsecureHttp }) + .build() + return ImageLoader.Builder(this) + .okHttpClient(guardedClient) .crossfade(true) .respectCacheHeaders(false) .diskCache { @@ -76,4 +89,5 @@ class PinakesApplication : Application(), ImageLoaderFactory { .build() } .build() + } } diff --git a/app/src/main/java/com/pinakes/app/data/network/CleartextGuard.kt b/app/src/main/java/com/pinakes/app/data/network/CleartextGuard.kt new file mode 100644 index 0000000..e9e7736 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/CleartextGuard.kt @@ -0,0 +1,45 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.store.SessionStore +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +/** + * App-level cleartext gate. + * + * The Android network-security-config permits cleartext at the OS level (it can't whitelist a + * runtime-entered instance host), so the app is the sole gatekeeper. This interceptor enforces + * the same "HTTPS required, except loopback / opted-in" rule as [NetworkModule.isTransportAllowed] + * for EVERY OkHttp client — not just the API one — so image loading (Coil) and the ebook PDF + * download can't silently downgrade to plain HTTP on an otherwise-HTTPS instance. + * + * A plain-HTTP request to a non-loopback host is refused unless the user has explicitly enabled + * the "Allow insecure HTTP" opt-in ([SessionStore.allowInsecureHttp]). + */ +class CleartextGuardInterceptor(private val allowInsecureHttp: () -> Boolean) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val url = chain.request().url + if (!url.isHttps && !isLoopback(url.host) && !allowInsecureHttp()) { + throw IOException( + "Cleartext HTTP blocked for ${url.host}. Enable \"Allow insecure HTTP\" to connect over plain HTTP.", + ) + } + return chain.proceed(chain.request()) + } + + private fun isLoopback(host: String): Boolean = + host == "localhost" || host == "127.0.0.1" || host == "10.0.2.2" || host == "::1" || host == "[::1]" +} + +/** Reaches the singleton [SessionStore] from code that can't be constructor-injected + * (the Coil [com.pinakes.app.PinakesApplication] image loader, the PDF downloader). */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface NetworkEntryPoint { + fun sessionStore(): SessionStore +} diff --git a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt index e6adc84..d765f96 100644 --- a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt +++ b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt @@ -28,6 +28,7 @@ class NetworkModule(private val session: SessionStore) { private val okHttpClient: OkHttpClient by lazy { val builder = OkHttpClient.Builder() + .addInterceptor(CleartextGuardInterceptor { session.allowInsecureHttp }) .addInterceptor(AuthInterceptor(session)) .connectTimeout(20, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) @@ -107,17 +108,18 @@ class NetworkModule(private val session: SessionStore) { companion object { /** * Derive the API base URL from a user-entered instance URL. - * - prepends `https://` when no scheme is given + * - when no scheme is given, prepends `https://` — or `http://` when the user has + * opted into insecure transport ([allowInsecure]); an explicit scheme is kept as-is * - strips a trailing slash / `/api/v1` if the user pasted it * - appends `/api/v1/` */ - fun deriveApiBaseUrl(rawInput: String): String { + fun deriveApiBaseUrl(rawInput: String, allowInsecure: Boolean = false): String { var s = rawInput.trim() if (s.isEmpty()) return s if (!s.startsWith("http://", ignoreCase = true) && !s.startsWith("https://", ignoreCase = true) ) { - s = "https://$s" + s = if (allowInsecure) "http://$s" else "https://$s" } s = s.trimEnd('/') // Strip any user-supplied /api or /api/v1 suffix to avoid duplication. @@ -126,15 +128,20 @@ class NetworkModule(private val session: SessionStore) { } /** Origin (scheme://host[:port]) for display, derived from the same raw input. */ - fun deriveOrigin(rawInput: String): String { - val api = deriveApiBaseUrl(rawInput) + fun deriveOrigin(rawInput: String, allowInsecure: Boolean = false): String { + val api = deriveApiBaseUrl(rawInput, allowInsecure) return api.removeSuffix("/api/v1/").trimEnd('/') } - /** HTTPS is required except for localhost / loopback addresses. */ - fun isTransportAllowed(apiBaseUrl: String): Boolean { + /** + * HTTPS is required except for localhost / loopback addresses — UNLESS the user has + * explicitly opted into insecure transport ([allowInsecure]), in which case any + * `http://` host is accepted (they own the risk; see the onboarding opt-in). + */ + fun isTransportAllowed(apiBaseUrl: String, allowInsecure: Boolean = false): Boolean { val lower = apiBaseUrl.lowercase() if (lower.startsWith("https://")) return true + if (allowInsecure && lower.startsWith("http://")) return true return lower.startsWith("http://localhost") || lower.startsWith("http://127.0.0.1") || lower.startsWith("http://10.0.2.2") || diff --git a/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt index 3e4aaa6..e8c850b 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt @@ -29,9 +29,9 @@ class AuthRepository( * Discovery against a candidate instance URL. Does not persist anything — the caller decides * to continue based on [HealthPayload.appAccessEnabled] and the transport warning. */ - suspend fun discover(rawInstanceUrl: String): ApiResult { - val apiBaseUrl = NetworkModule.deriveApiBaseUrl(rawInstanceUrl) - val origin = NetworkModule.deriveOrigin(rawInstanceUrl) + suspend fun discover(rawInstanceUrl: String, allowInsecure: Boolean = false): ApiResult { + val apiBaseUrl = NetworkModule.deriveApiBaseUrl(rawInstanceUrl, allowInsecure) + val origin = NetworkModule.deriveOrigin(rawInstanceUrl, allowInsecure) val api = network.api(apiBaseUrl) return when (val res = apiCall { api.health() }) { is ApiResult.Success -> ApiResult.Success( @@ -41,7 +41,8 @@ class AuthRepository( apiBaseUrl = apiBaseUrl, insecureTransport = res.meta?.warning == "insecure_transport" || res.meta?.https == false, - transportAllowed = NetworkModule.isTransportAllowed(apiBaseUrl), + transportAllowed = NetworkModule.isTransportAllowed(apiBaseUrl, allowInsecure), + allowInsecure = allowInsecure, ), res.meta, ) @@ -55,6 +56,7 @@ class AuthRepository( origin = discovery.origin, apiBaseUrl = discovery.apiBaseUrl, libraryName = discovery.health.name, + allowInsecure = discovery.allowInsecure, ) // Capture the instance feature flags from discovery so the UI is gated immediately. features.update(discovery.health) @@ -172,4 +174,6 @@ data class HealthDiscovery( val apiBaseUrl: String, val insecureTransport: Boolean, val transportAllowed: Boolean, + /** The user opted into plain-HTTP transport for this instance. */ + val allowInsecure: Boolean = false, ) diff --git a/app/src/main/java/com/pinakes/app/data/store/SessionStore.kt b/app/src/main/java/com/pinakes/app/data/store/SessionStore.kt index 0387ea3..b7b8b3a 100644 --- a/app/src/main/java/com/pinakes/app/data/store/SessionStore.kt +++ b/app/src/main/java/com/pinakes/app/data/store/SessionStore.kt @@ -45,6 +45,9 @@ class SessionStore(context: Context) { val libraryName: String? get() = prefs.getString(KEY_LIBRARY_NAME, null) + /** Whether the committed instance was accepted over insecure (plain HTTP) transport. */ + val allowInsecureHttp: Boolean get() = prefs.getBoolean(KEY_ALLOW_INSECURE, false) + /** A stable per-install device id used in the login request. Generated once. */ val deviceId: String get() = prefs.getString(KEY_DEVICE_ID, null) ?: UUID.randomUUID().toString().also { @@ -59,11 +62,12 @@ class SessionStore(context: Context) { * Persist the chosen instance. [origin] is the user-entered URL; [apiBaseUrl] is the * derived `${origin}/api/v1/`. Clears any existing token (new instance ⇒ new session). */ - fun saveInstance(origin: String, apiBaseUrl: String, libraryName: String?) { + fun saveInstance(origin: String, apiBaseUrl: String, libraryName: String?, allowInsecure: Boolean = false) { prefs.edit() .putString(KEY_INSTANCE_ORIGIN, origin) .putString(KEY_INSTANCE_URL, apiBaseUrl) .putString(KEY_LIBRARY_NAME, libraryName) + .putBoolean(KEY_ALLOW_INSECURE, allowInsecure) .remove(KEY_TOKEN) .apply() _authState.value = readAuthState() @@ -99,6 +103,7 @@ class SessionStore(context: Context) { private const val KEY_INSTANCE_URL = "instance_api_url" private const val KEY_TOKEN = "bearer_token" private const val KEY_LIBRARY_NAME = "library_name" + private const val KEY_ALLOW_INSECURE = "allow_insecure_http" private const val KEY_DEVICE_ID = "device_id" } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/PdfReaderDialog.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/PdfReaderDialog.kt index 0c658d8..6ee9354 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/PdfReaderDialog.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/PdfReaderDialog.kt @@ -39,6 +39,10 @@ import androidx.compose.ui.window.DialogProperties import com.pinakes.app.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import android.content.Context +import com.pinakes.app.data.network.CleartextGuardInterceptor +import com.pinakes.app.data.network.NetworkEntryPoint +import dagger.hilt.android.EntryPointAccessors import okhttp3.OkHttpClient import okhttp3.Request import java.io.File @@ -66,7 +70,7 @@ fun PdfReaderDialog( LaunchedEffect(pdfUrl) { state = withContext(Dispatchers.IO) { runCatching { - val file = downloadToCache(pdfUrl, context.cacheDir) + val file = downloadToCache(pdfUrl, context.cacheDir, guardedHttpClient(context)) val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) val renderer = PdfRenderer(pfd) PdfState.Ready(renderer.pageCount, renderer, pfd) as PdfState @@ -184,13 +188,26 @@ private fun PdfPage(renderer: PdfRenderer, index: Int, pageCount: Int) { } } -private val pdfHttpClient by lazy { OkHttpClient() } +/** + * OkHttp client for the PDF download, carrying the same cleartext gate as the API client so an + * ebook served over plain HTTP can't downgrade the connection on an HTTPS instance (allowed only + * for loopback or when the user opted into insecure HTTP). Reaches [SessionStore] via the Hilt + * EntryPoint since this download path isn't constructor-injected. + */ +private fun guardedHttpClient(context: Context): OkHttpClient { + val session = EntryPointAccessors + .fromApplication(context.applicationContext, NetworkEntryPoint::class.java) + .sessionStore() + return OkHttpClient.Builder() + .addInterceptor(CleartextGuardInterceptor { session.allowInsecureHttp }) + .build() +} /** Streams [url] into a temp file under [cacheDir] and returns it. Throws on a non-2xx response. */ -private fun downloadToCache(url: String, cacheDir: File): File { +private fun downloadToCache(url: String, cacheDir: File, client: OkHttpClient): File { val out = File.createTempFile("pinakes-ebook-", ".pdf", cacheDir) val request = Request.Builder().url(url).build() - pdfHttpClient.newCall(request).execute().use { response -> + client.newCall(request).execute().use { response -> if (!response.isSuccessful) { out.delete() error("HTTP ${response.code}") diff --git a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingScreen.kt index 9c0f1b2..0318107 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -105,6 +106,31 @@ fun OnboardingScreen(onContinue: () -> Unit) { Spacer(Modifier.height(Spacing.lg)) if (state.discovery == null) { + Row( + modifier = form, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.onboarding_allow_http_label), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + stringResource(R.string.onboarding_allow_http_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(Spacing.md)) + Switch( + checked = state.allowInsecureHttp, + onCheckedChange = vm::onAllowInsecureChange, + ) + } + + Spacer(Modifier.height(Spacing.lg)) + PrimaryButton( label = stringResource(R.string.action_discover_library), onClick = vm::discover, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt index e478931..43ad8a6 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt @@ -20,6 +20,8 @@ data class OnboardingUiState( val discovery: HealthDiscovery? = null, val error: String? = null, val errorRes: Int? = null, + /** User opt-in: allow a plain-HTTP instance (off by default, they own the risk). */ + val allowInsecureHttp: Boolean = false, ) /** @@ -36,6 +38,12 @@ class OnboardingViewModel @Inject constructor(private val auth: AuthRepository) _state.update { it.copy(url = value, error = null, errorRes = null, discovery = null) } } + /** Toggle the "allow insecure HTTP" opt-in; clears any stale discovery so the next + * probe re-derives the scheme (http vs https) from the new choice. */ + fun onAllowInsecureChange(value: Boolean) { + _state.update { it.copy(allowInsecureHttp = value, error = null, errorRes = null, discovery = null) } + } + fun discover() { val url = _state.value.url.trim() if (url.isBlank()) { @@ -44,7 +52,7 @@ class OnboardingViewModel @Inject constructor(private val auth: AuthRepository) } _state.update { it.copy(checking = true, error = null, errorRes = null, discovery = null) } viewModelScope.launch { - when (val res = auth.discover(url)) { + when (val res = auth.discover(url, _state.value.allowInsecureHttp)) { is ApiResult.Success -> _state.update { it.copy(checking = false, discovery = res.data, error = null, errorRes = null) } is ApiResult.Failure -> diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 4b0f8ad..2f81512 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,13 +1,30 @@ - + localhost 127.0.0.1 diff --git a/app/src/test/java/com/pinakes/app/NetworkUrlTest.kt b/app/src/test/java/com/pinakes/app/NetworkUrlTest.kt index 8f10f87..6b2d7f4 100644 --- a/app/src/test/java/com/pinakes/app/NetworkUrlTest.kt +++ b/app/src/test/java/com/pinakes/app/NetworkUrlTest.kt @@ -47,4 +47,34 @@ class NetworkUrlTest { assertTrue(NetworkModule.isTransportAllowed("http://127.0.0.1/api/v1/")) assertFalse(NetworkModule.isTransportAllowed("http://lib.example.org/api/v1/")) } + + // --- Insecure-HTTP opt-in (issue #16: HTTP-only self-hosted instances) --- + + @Test fun allowInsecurePrependsHttpForBareHost() { + assertEquals( + "http://lib.example.org/api/v1/", + NetworkModule.deriveApiBaseUrl("lib.example.org", allowInsecure = true), + ) + } + + @Test fun allowInsecureKeepsExplicitHttpsScheme() { + // An explicit scheme always wins over the opt-in default. + assertEquals( + "https://lib.example.org/api/v1/", + NetworkModule.deriveApiBaseUrl("https://lib.example.org", allowInsecure = true), + ) + } + + @Test fun allowInsecureOriginKeepsHttp() { + assertEquals("http://lib.example.org", NetworkModule.deriveOrigin("lib.example.org", allowInsecure = true)) + } + + @Test fun allowInsecurePermitsAnyHttpHost() { + assertTrue(NetworkModule.isTransportAllowed("http://lib.example.org/api/v1/", allowInsecure = true)) + } + + @Test fun defaultStillRejectsRemoteHttp() { + // Regression guard: without the opt-in, a remote HTTP host stays blocked. + assertFalse(NetworkModule.isTransportAllowed("http://lib.example.org/api/v1/", allowInsecure = false)) + } } diff --git a/i18n/de.json b/i18n/de.json index 3bc71ad..9e93467 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -39,6 +39,8 @@ "action_discover_library": "Bibliothek finden", "action_continue": "Weiter", "onboarding_http_warning": "Diese Bibliothek verwendet eine unsichere Verbindung (http). Verwende https, um fortzufahren.", + "onboarding_allow_http_label": "Unsicheres HTTP zulassen", + "onboarding_allow_http_hint": "Nur für eine selbst gehostete Instanz ohne HTTPS. Die Verbindung ist nicht verschlüsselt — Nutzung auf eigenes Risiko.", "onboarding_status_secure_ok": "Sichere Verbindung (https)", "onboarding_status_secure_warn": "Unsichere Verbindung (http) — nicht empfohlen", "onboarding_status_app_access_ok": "Der Zugriff über die mobile App ist aktiviert", diff --git a/i18n/en.json b/i18n/en.json index 81abf84..810e54e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -39,6 +39,8 @@ "action_discover_library": "Discover library", "action_continue": "Continue", "onboarding_http_warning": "This library uses an insecure (http) connection. Use https to continue.", + "onboarding_allow_http_label": "Allow insecure HTTP", + "onboarding_allow_http_hint": "Only for a self-hosted instance without HTTPS. The connection won’t be encrypted — use at your own risk.", "onboarding_status_secure_ok": "Secure connection (https)", "onboarding_status_secure_warn": "Insecure connection (http) — not recommended", "onboarding_status_app_access_ok": "Mobile app access is enabled", diff --git a/i18n/fr.json b/i18n/fr.json index f5d7a3f..b8d764c 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -39,6 +39,8 @@ "action_discover_library": "Trouver la bibliothèque", "action_continue": "Continuer", "onboarding_http_warning": "Cette bibliothèque utilise une connexion non sécurisée (http). Utilisez https pour continuer.", + "onboarding_allow_http_label": "Autoriser le HTTP non sécurisé", + "onboarding_allow_http_hint": "Uniquement pour une instance auto-hébergée sans HTTPS. La connexion ne sera pas chiffrée — à vos risques.", "onboarding_status_secure_ok": "Connexion sécurisée (https)", "onboarding_status_secure_warn": "Connexion non sécurisée (http) — déconseillée", "onboarding_status_app_access_ok": "L'accès depuis l'application mobile est activé", diff --git a/i18n/it.json b/i18n/it.json index 5e8293d..dbc3c7b 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -39,6 +39,8 @@ "action_discover_library": "Trova biblioteca", "action_continue": "Continua", "onboarding_http_warning": "Questa biblioteca usa una connessione non sicura (http). Usa https per continuare.", + "onboarding_allow_http_label": "Consenti HTTP non sicuro", + "onboarding_allow_http_hint": "Solo per un’istanza self-hosted senza HTTPS. La connessione non sarà cifrata — usalo a tuo rischio.", "onboarding_status_secure_ok": "Connessione sicura (https)", "onboarding_status_secure_warn": "Connessione non sicura (http) — sconsigliata", "onboarding_status_app_access_ok": "L'accesso da app mobile è attivo",