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
18 changes: 16 additions & 2 deletions app/src/main/java/com/pinakes/app/PinakesApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -76,4 +89,5 @@ class PinakesApplication : Application(), ImageLoaderFactory {
.build()
}
.build()
}
}
45 changes: 45 additions & 0 deletions app/src/main/java/com/pinakes/app/data/network/CleartextGuard.kt
Original file line number Diff line number Diff line change
@@ -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
}
21 changes: 14 additions & 7 deletions app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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") ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HealthDiscovery> {
val apiBaseUrl = NetworkModule.deriveApiBaseUrl(rawInstanceUrl)
val origin = NetworkModule.deriveOrigin(rawInstanceUrl)
suspend fun discover(rawInstanceUrl: String, allowInsecure: Boolean = false): ApiResult<HealthDiscovery> {
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(
Expand All @@ -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,
)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
)
7 changes: 6 additions & 1 deletion app/src/main/java/com/pinakes/app/data/store/SessionStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

/**
Expand All @@ -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()) {
Expand All @@ -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 ->
Expand Down
29 changes: 23 additions & 6 deletions app/src/main/res/xml/network_security_config.xml
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Cleartext (HTTP) is blocked by default on Android 9+. The Pinakes app only
permits an http instance URL for loopback / the emulator-to-host alias (mirrors
the app's own "HTTPS required except loopback" onboarding rule); every other
host must be HTTPS. So we whitelist cleartext for exactly those dev hosts and
keep the secure default everywhere else.
Transport policy is enforced in APP CODE, not here.

A static network-security-config can only whitelist cleartext for hosts known
at build time — but a self-hosted instance URL is entered by the user at
runtime, so we can't list it here. We therefore permit cleartext at the OS
level and make the app the sole gatekeeper:

* NetworkModule.isTransportAllowed() enforces "HTTPS required, except
loopback" for every instance URL, and
* the onboarding flow refuses to commit a plain-HTTP instance UNLESS the
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.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<base-config cleartextTrafficPermitted="true" />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
Expand Down
Loading
Loading