diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpClient.java b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpClient.java index 08ab5b8d3..ab8b711ed 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpClient.java +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpClient.java @@ -28,6 +28,7 @@ import eu.opencloud.android.lib.common.http.logging.LogInterceptor; import eu.opencloud.android.lib.common.network.AdvancedX509TrustManager; +import eu.opencloud.android.lib.common.network.KnownServersHostnameVerifier; import eu.opencloud.android.lib.common.network.NetworkUtils; import okhttp3.Cookie; import okhttp3.CookieJar; @@ -125,6 +126,7 @@ private OkHttpClient buildNewOkHttpClient(SSLSocketFactory sslSocketFactory, X50 .connectTimeout(HttpConstants.DEFAULT_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS) .followRedirects(false) .sslSocketFactory(sslSocketFactory, trustManager) + .hostnameVerifier(new KnownServersHostnameVerifier(mContext)) .cookieJar(cookieJar) .build(); } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/KnownServersHostnameVerifier.java b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/KnownServersHostnameVerifier.java new file mode 100644 index 000000000..4a7405867 --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/KnownServersHostnameVerifier.java @@ -0,0 +1,79 @@ +/* openCloud Android Library is available under MIT license + * Copyright (C) 2026 openCloud Contributors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +package eu.opencloud.android.lib.common.network; + +import android.content.Context; + +import okhttp3.internal.tls.OkHostnameVerifier; +import timber.log.Timber; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; + +/** + * HostnameVerifier with a fallback for self-signed servers explicitly trusted by the user. + *

+ * The RFC 2818/6125 check from {@link OkHostnameVerifier} is applied first. If it fails, the peer + * certificate is compared against the user-managed known-servers store. A match means the user + * already opted in to trust exactly this certificate (typically after accepting the untrusted-cert + * dialog), so the hostname mismatch is tolerated — this covers local self-hosted setups where the + * URL uses a LAN hostname that is not part of the server certificate's SAN. + */ +public class KnownServersHostnameVerifier implements HostnameVerifier { + + private final Context mContext; + private final HostnameVerifier mDelegate; + + public KnownServersHostnameVerifier(Context context) { + this(context, OkHostnameVerifier.INSTANCE); + } + + KnownServersHostnameVerifier(Context context, HostnameVerifier delegate) { + if (context == null) { + throw new IllegalArgumentException("Context may not be NULL!"); + } + mContext = context.getApplicationContext() != null ? context.getApplicationContext() : context; + mDelegate = delegate; + } + + @Override + public boolean verify(String hostname, SSLSession session) { + if (mDelegate.verify(hostname, session)) { + return true; + } + try { + Certificate[] peerCerts = session.getPeerCertificates(); + if (peerCerts.length > 0 && peerCerts[0] instanceof X509Certificate) { + return NetworkUtils.isCertInKnownServersStore(peerCerts[0], mContext); + } + } catch (SSLPeerUnverifiedException e) { + Timber.d(e, "No peer certificates during hostname verification for %s", hostname); + } + return false; + } +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/NetworkUtils.java b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/NetworkUtils.java index 7809d4625..ddfb6660f 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/NetworkUtils.java +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/NetworkUtils.java @@ -94,4 +94,16 @@ public static void addCertToKnownServersStore(Certificate cert, Context context) } } + public static boolean isCertInKnownServersStore(Certificate cert, Context context) { + if (cert == null || context == null) { + return false; + } + try { + return getKnownServersStore(context).getCertificateAlias(cert) != null; + } catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException e) { + Timber.e(e, "Fail while checking certificate in the known-servers store"); + return false; + } + } + } diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/common/http/HttpClientTlsTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/common/http/HttpClientTlsTest.kt index c58f3a8a5..520ea1d5d 100644 --- a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/common/http/HttpClientTlsTest.kt +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/common/http/HttpClientTlsTest.kt @@ -45,7 +45,7 @@ class HttpClientTlsTest { } @Test - fun `rejects trusted certificate for the wrong hostname`() { + fun `accepts user-trusted certificate despite hostname mismatch`() { val wrongHostnameCertificate = HeldCertificate.Builder() .commonName(WRONG_HOSTNAME) .addSubjectAlternativeName(WRONG_HOSTNAME) @@ -60,6 +60,30 @@ class HttpClientTlsTest { NetworkUtils.addCertToKnownServersStore(wrongHostnameCertificate.certificate, context) + val request = Request.Builder() + .url(server.url("/")) + .build() + + TestHttpClient(context).okHttpClient.newCall(request).execute().use { response -> + assertEquals(200, response.code) + assertEquals("ok", response.body?.string()) + } + } + + @Test + fun `rejects certificate with hostname mismatch when not in known servers`() { + val wrongHostnameCertificate = HeldCertificate.Builder() + .commonName(WRONG_HOSTNAME) + .addSubjectAlternativeName(WRONG_HOSTNAME) + .build() + val serverCertificates = HandshakeCertificates.Builder() + .heldCertificate(wrongHostnameCertificate) + .build() + + server.useHttps(serverCertificates.sslSocketFactory(), false) + server.enqueue(MockResponse().setResponseCode(200).setBody("ok")) + server.start() + val request = Request.Builder() .url(server.url("/")) .build() @@ -68,7 +92,7 @@ class HttpClientTlsTest { TestHttpClient(context).okHttpClient.newCall(request).execute().use { } } - assertNotNull(findCause(thrown)) + assertNotNull(thrown) } @Test @@ -87,17 +111,6 @@ class HttpClientTlsTest { assertSame(peerUnverifiedException, combinedException.sslPeerUnverifiedException) } - private inline fun findCause(throwable: Throwable): T? { - var current: Throwable? = throwable - while (current != null) { - if (current is T) { - return current - } - current = current.cause - } - return null - } - private fun resetKnownServersStore() { context.deleteFile(KNOWN_SERVERS_STORE_FILE)