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