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
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ android {
applicationId = "com.pinakes.app"
minSdk = 26
targetSdk = 35
versionCode = 6
versionName = "1.3.0"
versionCode = 7
versionName = "1.3.1"

vectorDrawables {
useSupportLibrary = true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package com.pinakes.app.data.network

import android.util.Log
import com.pinakes.app.BuildConfig
import java.io.ByteArrayInputStream
import java.net.HttpURLConnection
import java.net.Socket
import java.net.URL
import java.security.KeyStore
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.ConcurrentHashMap
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedTrustManager
import javax.net.ssl.X509TrustManager

/**
* An [X509ExtendedTrustManager] that completes an incomplete server certificate chain by fetching
* the missing intermediate CA certificate(s) from the leaf's Authority Information Access (AIA)
* "CA Issuers" URL — exactly what a browser does, and what OkHttp / Android's default TLS stack
* does NOT.
*
* Why: self-hosted Pinakes instances behind QNAP / Synology reverse proxies routinely serve an
* incomplete chain (leaf only, or leaf + wrong intermediate). Browsers — and Firefox with its own
* store — paper over this by fetching the intermediate from the AIA URL; the native app, using the
* system trust store with no AIA fetching, fails the handshake with
* "Trust anchor for certification path not found" even though the certificate is a perfectly valid
* Let's Encrypt one whose root Android already trusts. (Reproduced against a real user's QNAP
* instance in the emulator: `letsencrypt.org` validates, the user's host does not.)
*
* It extends [X509ExtendedTrustManager] (not the plain [X509TrustManager]) because an app with a
* domain-specific network-security-config makes Android's platform trust manager reject the
* hostname-unaware two-arg `checkServerTrusted`, demanding the socket/engine-aware overloads.
*
* Security: this NEVER weakens validation. The final decision is always made by the platform
* default trust manager (with the real peer hostname, via the socket/engine overloads) against the
* system trust store. We only ADD intermediate certificates fetched via AIA and re-validate; a
* fetched certificate that does not cryptographically chain to a trusted root is rejected exactly
* as before, and on any failure we re-throw the ORIGINAL exception so genuinely untrusted certs
* fail identically. No new trust anchors are introduced.
*/
class AiaCompletingTrustManager(
private val delegate: X509ExtendedTrustManager,
) : X509ExtendedTrustManager() {

private val certFactory = CertificateFactory.getInstance("X.509")

/** Fetched intermediates cached by AIA URL for the process lifetime (handshake-hot path). */
private val cache = ConcurrentHashMap<String, X509Certificate>()

// ── Server trust: complete the chain via AIA, then defer to the platform (hostname-aware). ──

override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String, socket: Socket?) =
completeThenValidate(chain) { delegate.checkServerTrusted(it, authType, socket) }

override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String, engine: SSLEngine?) =
completeThenValidate(chain) { delegate.checkServerTrusted(it, authType, engine) }

override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String) =
completeThenValidate(chain) { delegate.checkServerTrusted(it, authType) }

// ── Client trust + accepted issuers: straight delegation, never touched. ──

override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String, socket: Socket?) =
delegate.checkClientTrusted(chain, authType, socket)

override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String, engine: SSLEngine?) =
delegate.checkClientTrusted(chain, authType, engine)

override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String) =
delegate.checkClientTrusted(chain, authType)

override fun getAcceptedIssuers(): Array<X509Certificate> = delegate.acceptedIssuers

/**
* Run [validate] on the server-supplied chain; if it throws, try again on an AIA-completed chain.
* On any completion/re-validation failure the ORIGINAL exception is surfaced, so the behaviour
* for genuinely untrusted certificates is byte-for-byte unchanged.
*/
private inline fun completeThenValidate(
chain: Array<out X509Certificate>,
validate: (Array<X509Certificate>) -> Unit,
) {
try {
@Suppress("UNCHECKED_CAST")
validate(chain as Array<X509Certificate>)
return
} catch (original: CertificateException) {
val completed = runCatching { completeChain(chain.toList()) }.getOrNull()
if (completed == null || completed.size == chain.size) throw original
try {
validate(completed.toTypedArray())
if (BuildConfig.DEBUG) {
Log.i(TAG, "AIA-completed chain (${chain.size} -> ${completed.size}) validated.")
}
} catch (stillUntrusted: CertificateException) {
throw original
}
}
}

/** Walk up from the server-supplied chain, fetching each missing issuer via AIA, until a
* self-issued cert is reached, the issuer is already present, or the depth cap is hit. */
private fun completeChain(initial: List<X509Certificate>): List<X509Certificate> {
val chain = ArrayList(initial)
var depth = 0
while (depth++ < MAX_FETCH) {
val top = chain.last()
if (top.subjectX500Principal == top.issuerX500Principal) break // self-issued root
if (chain.any { it.subjectX500Principal == top.issuerX500Principal }) break // issuer already here
val issuer = fetchIssuer(top) ?: break
if (issuer.subjectX500Principal != top.issuerX500Principal) break // fetched wrong cert
chain.add(issuer)
}
return chain
}

private fun fetchIssuer(cert: X509Certificate): X509Certificate? {
for (url in caIssuerUrls(cert)) {
cache[url]?.let { return it }
val fetched = runCatching { download(url) }.getOrNull() ?: continue
cache[url] = fetched
return fetched
}
return null
}

private fun download(url: String): X509Certificate? {
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = FETCH_TIMEOUT_MS
readTimeout = FETCH_TIMEOUT_MS
instanceFollowRedirects = true
}
Comment on lines +132 to +137

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and surrounding symbols.
git ls-files 'app/src/main/java/com/pinakes/app/data/network/AiaCompletingTrustManager.kt'
wc -l app/src/main/java/com/pinakes/app/data/network/AiaCompletingTrustManager.kt
ast-grep outline app/src/main/java/com/pinakes/app/data/network/AiaCompletingTrustManager.kt --view expanded

# Show the relevant section with line numbers.
sed -n '1,260p' app/src/main/java/com/pinakes/app/data/network/AiaCompletingTrustManager.kt

Repository: fabiodalez-dev/Pinakes-Android

Length of output: 10556


Blocca il fetch AIA verso host non fidati
caIssuerUrls legge URL da un certificato non ancora attendibile e download esegue una GET con redirect automatici, senza allowlist né filtro per indirizzi privati/loopback/link-local. Un certificato malevolo può quindi indurre richieste verso LAN o servizi locali durante l’handshake. Rifiuta questi indirizzi prima della connessione e dopo ogni redirect, oppure disabilita i redirect.

🧰 Tools
🪛 ast-grep (0.44.1)

[error] 132-132: An outbound HTTP request is built from a non-literal URL: URL(...).openConnection(), OkHttp's Request.Builder().url(...), or a Spring RestTemplate call (getForObject/getForEntity/postForObject/postForEntity/exchange/execute). When the URL is derived from a variable, method call, concatenation, or interpolation, an attacker can point the request at internal services, cloud metadata endpoints (e.g. 169.254.169.254), or arbitrary hosts (Server-Side Request Forgery). Do not build request targets from untrusted input: validate the URL against a strict allowlist of permitted hosts/schemes, resolve and re-check the host after DNS resolution, and reject internal/link-local/loopback addresses before connecting.
Context: URL(url).openConnection()
Note: [CWE-918] Server-Side Request Forgery (SSRF).

(ssrf-outbound-request-tainted-url-kotlin)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/pinakes/app/data/network/AiaCompletingTrustManager.kt`
around lines 132 - 137, The download flow in AiaCompletingTrustManager.download
currently trusts caIssuerUrls and follows redirects without validating the
destination. Before opening the connection, and again after each redirect if
redirects remain enabled, reject any URL whose host resolves to private,
loopback, or link-local addresses; alternatively disable automatic redirects
entirely. Keep the check close to download and any helper that consumes the URL
so only trusted AIA fetch targets are allowed.

Source: Linters/SAST tools

return try {
val bytes = conn.inputStream.use { it.readBytes() }
// AIA "CA Issuers" is usually a single DER certificate; CertificateFactory also parses
// PEM. A non-certificate body (e.g. an OCSP responder answering a bare GET) throws, and
// the caller simply tries the next URL.
certFactory.generateCertificate(ByteArrayInputStream(bytes)) as? X509Certificate
} catch (t: Throwable) {
null
} finally {
conn.disconnect()
}
}
Comment on lines +138 to +149

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Lettura non limitata: readBytes() può causare OOM da un endpoint AIA malevolo.

conn.inputStream.use { it.readBytes() } legge l'intero corpo della risposta senza alcun limite di dimensione, su un percorso attivato da un host non fidato e durante l'handshake. Un endpoint AIA che restituisce un corpo di grandi dimensioni può esaurire la memoria e far crashare il processo. Un certificato DER/PEM è di dimensioni ridotte: imponi un tetto massimo di lettura e interrompi oltre tale soglia.

🛡️ Esempio di lettura limitata
         return try {
-            val bytes = conn.inputStream.use { it.readBytes() }
+            val bytes = conn.inputStream.use { input ->
+                input.readNBytes(MAX_CERT_BYTES + 1)
+            }
+            if (bytes.size > MAX_CERT_BYTES) return null
             // AIA "CA Issuers" is usually a single DER certificate; CertificateFactory also parses
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return try {
val bytes = conn.inputStream.use { it.readBytes() }
// AIA "CA Issuers" is usually a single DER certificate; CertificateFactory also parses
// PEM. A non-certificate body (e.g. an OCSP responder answering a bare GET) throws, and
// the caller simply tries the next URL.
certFactory.generateCertificate(ByteArrayInputStream(bytes)) as? X509Certificate
} catch (t: Throwable) {
null
} finally {
conn.disconnect()
}
}
return try {
val bytes = conn.inputStream.use { input ->
input.readNBytes(MAX_CERT_BYTES + 1)
}
if (bytes.size > MAX_CERT_BYTES) return null
// AIA "CA Issuers" is usually a single DER certificate; CertificateFactory also parses
// PEM. A non-certificate body (e.g. an OCSP responder answering a bare GET) throws, and
// the caller simply tries the next URL.
certFactory.generateCertificate(ByteArrayInputStream(bytes)) as? X509Certificate
} catch (t: Throwable) {
null
} finally {
conn.disconnect()
}
}
🧰 Tools
🪛 detekt (1.23.8)

[warning] 144-144: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/pinakes/app/data/network/AiaCompletingTrustManager.kt`
around lines 138 - 149, The AIA fetch path in AiaCompletingTrustManager is
reading an unbounded response body via conn.inputStream.use { it.readBytes() },
which can trigger OOM during handshake. Update the certificate download logic in
the helper that parses the AIA “CA Issuers” response to enforce a small maximum
byte limit while reading, stop once the limit is exceeded, and return null for
oversized responses before passing bytes to certFactory.generateCertificate.


/** Every ASCII http(s) URL embedded in the AIA extension (OID 1.3.6.1.5.5.7.1.1). We skip a
* full ASN.1 dependency: OCSP URLs are naturally filtered out downstream because [download]
* returns null for anything that doesn't parse as a certificate. */
private fun caIssuerUrls(cert: X509Certificate): List<String> {
val ext = cert.getExtensionValue(AIA_OID) ?: return emptyList()
val text = String(ext, Charsets.ISO_8859_1)
return URL_REGEX.findAll(text).map { it.value }.distinct().toList()
}

companion object {
private const val TAG = "AiaTrust"
private const val AIA_OID = "1.3.6.1.5.5.7.1.1"
private const val MAX_FETCH = 4
private const val FETCH_TIMEOUT_MS = 5_000
private val URL_REGEX = Regex("""https?://[A-Za-z0-9._~:/?#\[\]@!${'$'}&'()*+,;=%-]+""")

/**
* Platform default trust manager wrapped so incomplete chains are completed via AIA. Returns
* the socket factory + trust manager pair OkHttp's `sslSocketFactory(factory, tm)` needs.
*/
fun sslSocketFactory(): Pair<SSLSocketFactory, X509TrustManager> {
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(null as KeyStore?)
val system = tmf.trustManagers.filterIsInstance<X509ExtendedTrustManager>().first()
val aia = AiaCompletingTrustManager(system)
val ctx = SSLContext.getInstance("TLS")
ctx.init(null, arrayOf(aia), null)
return ctx.socketFactory to aia
}
}
}
10 changes: 10 additions & 0 deletions app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ class NetworkModule(private val session: SessionStore) {
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
// Complete incomplete server certificate chains via AIA, like a browser does. Self-hosted
// instances behind QNAP/Synology proxies often serve a chain missing its intermediate, which
// the default Android TLS stack rejects ("Trust anchor for certification path not found")
// even for valid Let's Encrypt certs. This never weakens validation — the platform trust
// manager still makes the final decision. Guarded so any setup failure falls back to the
// default TLS behaviour rather than breaking the client.
runCatching {
val (factory, trustManager) = AiaCompletingTrustManager.sslSocketFactory()
builder.sslSocketFactory(factory, trustManager)
}
if (BuildConfig.DEBUG) {
builder.addInterceptor(
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
Expand Down
Loading