Skip to content

fix(tls): complete incomplete server cert chains via AIA fetching#24

Merged
fabiodalez-dev merged 2 commits into
mainfrom
fix/tls-aia-chain-completion
Jul 9, 2026
Merged

fix(tls): complete incomplete server cert chains via AIA fetching#24
fabiodalez-dev merged 2 commits into
mainfrom
fix/tls-aia-chain-completion

Conversation

@fabiodalez-dev

@fabiodalez-dev fabiodalez-dev commented Jul 9, 2026

Copy link
Copy Markdown
Owner

A self-hosted instance behind a QNAP/Synology reverse proxy often serves an incomplete TLS chain (leaf only, or leaf + an intermediate that does not reach a trusted root). Browsers — and Firefox with its own trust store — paper over this by fetching the missing intermediate from the certificate's AIA "CA Issuers" URL. The app, using the Android system trust store with no AIA fetching, instead failed the handshake with Trust anchor for certification path not found, so onboarding never got past Discover library for a perfectly valid Let's Encrypt certificate.

Reproduction (emulator, real user QNAP host)

Target Before After
letsencrypt.org (standard LE) ✅ 200 ✅ 200
user's QNAP instance Trust anchor for certification path not found AIA-completed chain (2 -> 4) validated200, discovery + login proceed

What it does

AiaCompletingTrustManager wraps the platform X509ExtendedTrustManager: on a validation failure it walks the chain, downloads the missing issuer(s) from the AIA URL, and re-validates. It extends X509ExtendedTrustManager (not the plain X509TrustManager) because a domain-specific network-security-config makes the platform trust manager reject the hostname-unaware two-arg checkServerTrusted.

Security

Validation is never weakened. The platform trust manager still makes the final decision, with the real peer hostname, against the system trust store. Only intermediates that cryptographically chain to an already-trusted root are added; on any failure the ORIGINAL exception is re-thrown, so genuinely untrusted certs fail identically. Wired with runCatching so any TLS setup issue falls back to default behaviour.

Summary by CodeRabbit

  • New Features
    • Migliorata la gestione delle connessioni sicure: l’app può ora completare automaticamente catene di certificati TLS incomplete quando il server non invia tutti i certificati intermedi.
  • Bug Fixes
    • Ridotti i fallimenti di connessione con alcuni server, mantenendo il comportamento normale per i certificati realmente non attendibili.
    • Se il recupero dei certificati aggiuntivi non riesce, l’app continua a usare la configurazione TLS standard senza interrompersi.

A self-hosted instance behind a QNAP/Synology reverse proxy often serves an
incomplete TLS chain (leaf only, or leaf + an intermediate that doesn't reach a
trusted root). Browsers — and Firefox with its own trust store — paper over this
by fetching the missing intermediate from the certificate's AIA "CA Issuers" URL.
The app, using the Android system trust store with no AIA fetching, instead failed
the handshake with "Trust anchor for certification path not found", so onboarding
never got past "Discover library" for a perfectly valid Let's Encrypt certificate.

Reproduced in the emulator against a real user's QNAP instance: letsencrypt.org
validated fine, the user's host did not. With this change the same host completes
the chain (2 -> 4 certs) and /api/v1/health returns 200, discovery + login proceed.

AiaCompletingTrustManager wraps the platform X509ExtendedTrustManager: on a
validation failure it walks the chain, downloads the missing issuer(s) from the
AIA URL, and re-validates. It extends X509ExtendedTrustManager (not the plain
X509TrustManager) because a domain-specific network-security-config makes the
platform trust manager reject the hostname-unaware two-arg checkServerTrusted.

Security: validation is never weakened. The platform trust manager still makes the
final decision, with the real peer hostname, against the system trust store. Only
intermediates that cryptographically chain to an already-trusted root are added;
on any failure the ORIGINAL exception is re-thrown, so genuinely untrusted certs
fail identically. Guarded with runCatching so any TLS setup issue falls back to
the default behaviour.
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@fabiodalez-dev, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 55 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a005c7aa-25b0-4805-969c-928da578cb88

📥 Commits

Reviewing files that changed from the base of the PR and between 8a604a7 and 2d26dca.

📒 Files selected for processing (1)
  • app/build.gradle.kts

Walkthrough

Introdotta una nuova classe AiaCompletingTrustManager che completa catene di certificati TLS incomplete recuperando gli intermedi mancanti tramite gli URL AIA "CA Issuers". NetworkModule è aggiornato per usare questo trust manager nella configurazione dell'OkHttpClient, con fallback protetto in caso di errore.

Changes

Completamento catena certificati via AIA

Layer / File(s) Summary
Implementazione AiaCompletingTrustManager
app/src/main/java/com/pinakes/app/data/network/AiaCompletingTrustManager.kt
Nuova classe che estende X509ExtendedTrustManager, valida prima la catena ricevuta, in caso di CertificateException tenta il completamento via estensione AIA scaricando certificati intermedi tramite HttpURLConnection con cache ConcurrentHashMap, e fornisce la factory sslSocketFactory() per ottenere SSLSocketFactory e X509TrustManager.
Integrazione in NetworkModule
app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt
OkHttpClient configurato per usare sslSocketFactory/trustManager da AiaCompletingTrustManager, con applicazione protetta da runCatching per ricadere sul comportamento TLS predefinito in caso di errore.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OkHttpClient
  participant NetworkModule
  participant AiaCompletingTrustManager
  participant AIAServer

  NetworkModule->>AiaCompletingTrustManager: sslSocketFactory()
  AiaCompletingTrustManager->>NetworkModule: SSLSocketFactory, X509TrustManager
  NetworkModule->>OkHttpClient: applica sslSocketFactory/trustManager
  OkHttpClient->>AiaCompletingTrustManager: checkServerTrusted(chain)
  AiaCompletingTrustManager->>AiaCompletingTrustManager: valida catena ricevuta
  alt CertificateException
    AiaCompletingTrustManager->>AIAServer: fetch certificato intermedio (AIA URL)
    AIAServer->>AiaCompletingTrustManager: certificato scaricato
    AiaCompletingTrustManager->>AiaCompletingTrustManager: valida catena completata
  end
  AiaCompletingTrustManager->>OkHttpClient: risultato validazione
Loading

Poem

Un coniglio scava per l'anello che manca,
tra URL AIA la catena non stanca,
se il certificato non torna a casa,
scarico l'intermedio e la fido con calma. 🐇🔐
Salti sicuri, TLS non trema più!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Il titolo riassume chiaramente il cambio principale: completamento delle catene TLS incomplete tramite recupero AIA.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tls-aia-chain-completion

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@app/src/main/java/com/pinakes/app/data/network/AiaCompletingTrustManager.kt`:
- Around line 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 82896306-e1c1-4f92-8d05-91ac70ef493e

📥 Commits

Reviewing files that changed from the base of the PR and between b0aa8d7 and 8a604a7.

📒 Files selected for processing (2)
  • app/src/main/java/com/pinakes/app/data/network/AiaCompletingTrustManager.kt
  • app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt

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

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

Comment on lines +138 to +149
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()
}
}

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.

@fabiodalez-dev fabiodalez-dev merged commit cfdbf4f into main Jul 9, 2026
1 of 2 checks passed
@fabiodalez-dev fabiodalez-dev deleted the fix/tls-aia-chain-completion branch July 9, 2026 08:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant