From 90df8c01218458e53eeec1be8501079916d452c7 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 6 Jul 2026 22:07:57 +0200 Subject: [PATCH 1/2] feat(onboarding): opt-in "Allow insecure HTTP" for HTTP-only self-hosted instances (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosters who run Pinakes over plain HTTP (e.g. a QNAP/Docker instance with no TLS) couldn't connect: the app requires HTTPS for any non-loopback host, so a bare host is upgraded to https:// (which the server doesn't answer) and an explicit http:// URL is blocked by the OS cleartext policy — surfacing as the generic "Couldn't reach that address" while the same instance opens fine in a browser. Adds an explicit, off-by-default opt-in so those users can connect at their own risk without weakening the default for everyone: - network_security_config: a static config can't whitelist a runtime-entered host, so cleartext is permitted at the OS level and the app becomes the sole gatekeeper. - NetworkModule.deriveApiBaseUrl()/isTransportAllowed() take an allowInsecure flag: when set, a bare host is prefixed http:// and any http:// host is accepted; otherwise the existing "HTTPS required except loopback" rule is unchanged. - SessionStore persists the per-instance allow_insecure_http flag; AuthRepository threads it through discover()/commitInstance(). - Onboarding shows an "Allow insecure HTTP" switch (off by default) with a clear "connection won't be encrypted — use at your own risk" hint, in all 4 locales. Without the toggle the app still refuses plain-HTTP remote instances exactly as before. Unit tests cover both the opt-in path and the default-rejects-remote-http regression. Verified: compileDebugKotlin + NetworkUrlTest pass. --- .../pinakes/app/data/network/NetworkModule.kt | 20 ++++++++----- .../app/data/repository/AuthRepository.kt | 12 +++++--- .../pinakes/app/data/store/SessionStore.kt | 7 ++++- .../ui/screens/onboarding/OnboardingScreen.kt | 26 ++++++++++++++++ .../screens/onboarding/OnboardingViewModel.kt | 10 ++++++- .../main/res/xml/network_security_config.xml | 23 ++++++++++---- .../java/com/pinakes/app/NetworkUrlTest.kt | 30 +++++++++++++++++++ i18n/de.json | 2 ++ i18n/en.json | 2 ++ i18n/fr.json | 2 ++ i18n/it.json | 2 ++ 11 files changed, 117 insertions(+), 19 deletions(-) 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..60af386 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 @@ -107,17 +107,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 +127,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/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..e78c0c1 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,13 +1,24 @@ - + 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", From bb862088d004594d012a8b483cb85bc6457f4fc2 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 6 Jul 2026 22:27:23 +0200 Subject: [PATCH 2/2] fix(security): route Coil + PDF loaders through the cleartext gate too (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opt-in flipped the network-security-config base to cleartextTrafficPermitted=true, which alone would let ANY loader do plain HTTP — not just the API client that goes through NetworkModule.isTransportAllowed(). Coil (book covers) and the ebook PDF download each build their own OkHttpClient and bypassed that gate, so on an HTTPS instance an http:// cover/PDF URL could have downgraded silently. Add a shared CleartextGuardInterceptor (blocks http:// to a non-loopback host unless SessionStore.allowInsecureHttp) and wire it into all three OkHttp clients: NetworkModule, the Coil ImageLoader (PinakesApplication, via a Hilt EntryPoint) and PdfReaderDialog's downloader. The app is now the real universal gate; cleartext is only ever issued for loopback/dev hosts or an instance the user knowingly accepted as insecure. Verified: compileDebugKotlin passes. --- .../com/pinakes/app/PinakesApplication.kt | 18 +++++++- .../app/data/network/CleartextGuard.kt | 45 +++++++++++++++++++ .../pinakes/app/data/network/NetworkModule.kt | 1 + .../app/ui/screens/detail/PdfReaderDialog.kt | 25 +++++++++-- .../main/res/xml/network_security_config.xml | 6 +++ 5 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/data/network/CleartextGuard.kt 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 60af386..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) 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/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index e78c0c1..2f81512 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -13,6 +13,12 @@ user has explicitly enabled the "Allow insecure HTTP" opt-in (off by default — see OnboardingViewModel / SessionStore.allowInsecureHttp). + The gate covers EVERY OkHttp client, not just the API one — the + CleartextGuardInterceptor is wired into NetworkModule, the Coil image loader + (PinakesApplication) and the ebook PDF downloader (PdfReaderDialog) — so a + cover or PDF served over plain HTTP cannot silently downgrade the connection + on an otherwise-HTTPS instance either. + So a cleartext request is only ever issued for loopback/dev hosts or for an instance the user has knowingly accepted as insecure. The domain-config below is kept as belt-and-braces documentation of the always-allowed dev hosts.