diff --git a/changelog/unreleased/4861 b/changelog/unreleased/4861
new file mode 100644
index 00000000000..8bd429bf3b9
--- /dev/null
+++ b/changelog/unreleased/4861
@@ -0,0 +1,6 @@
+Security: SSL certificate verification and trusted host handling
+
+SSL certificate verification flow has been improved by enhancing
+host validation and trusted certificate handling.
+
+https://github.com/owncloud/android/pull/4861
diff --git a/owncloudApp/src/main/java/com/owncloud/android/ui/dialog/SslUntrustedCertDialog.java b/owncloudApp/src/main/java/com/owncloud/android/ui/dialog/SslUntrustedCertDialog.java
index 97dffec8f28..946484c605d 100644
--- a/owncloudApp/src/main/java/com/owncloud/android/ui/dialog/SslUntrustedCertDialog.java
+++ b/owncloudApp/src/main/java/com/owncloud/android/ui/dialog/SslUntrustedCertDialog.java
@@ -4,7 +4,9 @@
* @author masensio
* @author David A. Velasco
* @author Christian Schabesberger
- * Copyright (C) 2020 ownCloud GmbH.
+ *
+ * Copyright (C) 2026 ownCloud GmbH.
+ *
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
@@ -82,7 +84,7 @@ public static SslUntrustedCertDialog newInstanceForFullSslError(CertificateCombi
throw new IllegalArgumentException("Trying to create instance with parameter sslException == null");
}
SslUntrustedCertDialog dialog = new SslUntrustedCertDialog();
- dialog.m509Certificate = sslException.getServerCertificate();
+ dialog.m509Certificate = sslException.getSslPeerUnverifiedException() == null ? sslException.getServerCertificate() : null;
dialog.mErrorViewAdapter = new CertificateCombinedExceptionViewAdapter(sslException);
dialog.mCertificateViewAdapter = new X509CertificateViewAdapter(sslException.getServerCertificate());
return dialog;
@@ -144,6 +146,12 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa
Button cancel = mView.findViewById(R.id.btnCancel);
cancel.setOnClickListener(new OnCertificateNotTrusted());
+ if (m509Certificate == null) {
+ ok.setText(android.R.string.ok);
+ mView.findViewById(R.id.question).setVisibility(View.GONE);
+ cancel.setVisibility(View.GONE);
+ }
+
Button details = mView.findViewById(R.id.details_btn);
details.setOnClickListener(new OnClickListener() {
@@ -219,6 +227,8 @@ public void onClick(View v) {
((OnSslUntrustedCertListener) activity).onFailedSavingCertificate();
Timber.e(e, "Server certificate could not be saved in the known-servers trust store ");
}
+ } else if (mHandler == null) {
+ ((OnSslUntrustedCertListener) getActivity()).onCancelCertificate();
}
}
diff --git a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/http/HttpClient.java b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/http/HttpClient.java
index e141320fda2..baadc1ec9fd 100644
--- a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/http/HttpClient.java
+++ b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/http/HttpClient.java
@@ -1,5 +1,7 @@
-/* ownCloud Android Library is available under MIT license
- * Copyright (C) 2020 ownCloud GmbH.
+/**
+ * ownCloud Android Library is available under MIT license
+ *
+ * Copyright (C) 2026 ownCloud GmbH.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -28,6 +30,7 @@
import com.owncloud.android.lib.common.http.logging.LogInterceptor;
import com.owncloud.android.lib.common.network.AdvancedX509TrustManager;
+import com.owncloud.android.lib.common.network.KnownServersHostnameVerifier;
import com.owncloud.android.lib.common.network.NetworkUtils;
import okhttp3.Cookie;
import okhttp3.CookieJar;
@@ -125,7 +128,7 @@ private OkHttpClient buildNewOkHttpClient(SSLSocketFactory sslSocketFactory, X50
.connectTimeout(HttpConstants.DEFAULT_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS)
.followRedirects(false)
.sslSocketFactory(sslSocketFactory, trustManager)
- .hostnameVerifier((asdf, usdf) -> true)
+ .hostnameVerifier(new KnownServersHostnameVerifier(mContext))
.cookieJar(cookieJar)
.build();
}
diff --git a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/network/AdvancedX509TrustManager.java b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/network/AdvancedX509TrustManager.java
index b84c03de766..9d8dd786d31 100644
--- a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/network/AdvancedX509TrustManager.java
+++ b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/network/AdvancedX509TrustManager.java
@@ -1,5 +1,7 @@
-/* ownCloud Android Library is available under MIT license
- * Copyright (C) 2016 ownCloud GmbH.
+/**
+ * ownCloud Android Library is available under MIT license
+ *
+ * Copyright (C) 2026 ownCloud GmbH.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -84,11 +86,16 @@ public void checkClientTrusted(X509Certificate[] certificates, String authType)
mStandardTrustManager.checkClientTrusted(certificates, authType);
}
+ public static final ThreadLocal sLastCert = new ThreadLocal<>();
+
/**
* @see javax.net.ssl.X509TrustManager#checkServerTrusted(X509Certificate[],
* String authType)
*/
public void checkServerTrusted(X509Certificate[] certificates, String authType) {
+ if (certificates != null && certificates.length > 0) {
+ sLastCert.set(certificates[0]);
+ }
if (!isKnownServer(certificates[0])) {
CertificateCombinedException result = new CertificateCombinedException(certificates[0]);
try {
diff --git a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/network/KnownServersHostnameVerifier.java b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/network/KnownServersHostnameVerifier.java
new file mode 100644
index 00000000000..49ad16fc3d4
--- /dev/null
+++ b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/network/KnownServersHostnameVerifier.java
@@ -0,0 +1,62 @@
+/**
+ * ownCloud Android client application
+ *
+ * Copyright (C) 2026 ownCloud GmbH.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.owncloud.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;
+
+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/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/network/NetworkUtils.java b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/network/NetworkUtils.java
index f6013cb8498..541bce0d033 100644
--- a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/network/NetworkUtils.java
+++ b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/network/NetworkUtils.java
@@ -1,5 +1,7 @@
-/* ownCloud Android Library is available under MIT license
- * Copyright (C) 2016 ownCloud GmbH.
+/**
+ * ownCloud Android Library is available under MIT license
+ *
+ * Copyright (C) 2026 ownCloud GmbH.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -94,4 +96,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/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/operations/RemoteOperationResult.java b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/operations/RemoteOperationResult.java
index b975417ea86..5fdeeabe913 100644
--- a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/operations/RemoteOperationResult.java
+++ b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/operations/RemoteOperationResult.java
@@ -1,5 +1,7 @@
-/* ownCloud Android Library is available under MIT license
- * Copyright (C) 2022 ownCloud GmbH.
+/**
+ * ownCloud Android Library is available under MIT license
+ *
+ * Copyright (C) 2026 ownCloud GmbH.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -32,6 +34,7 @@
import com.owncloud.android.lib.common.accounts.AccountUtils;
import com.owncloud.android.lib.common.http.HttpConstants;
import com.owncloud.android.lib.common.http.methods.HttpBaseMethod;
+import com.owncloud.android.lib.common.network.AdvancedX509TrustManager;
import com.owncloud.android.lib.common.network.CertificateCombinedException;
import okhttp3.Headers;
import org.apache.commons.lang3.exception.ExceptionUtils;
@@ -50,6 +53,7 @@
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
+import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -148,6 +152,12 @@ public RemoteOperationResult(Exception e) {
} else if (e instanceof SSLException || e instanceof RuntimeException) {
if (e instanceof SSLPeerUnverifiedException) {
+ X509Certificate lastCert = AdvancedX509TrustManager.sLastCert.get();
+ AdvancedX509TrustManager.sLastCert.remove();
+ CertificateCombinedException sslPeerUnverifiedException = new CertificateCombinedException(lastCert);
+ sslPeerUnverifiedException.setSslPeerUnverifiedException((SSLPeerUnverifiedException) e);
+ sslPeerUnverifiedException.initCause(e);
+ mException = sslPeerUnverifiedException;
mCode = ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED;
} else {
CertificateCombinedException se = getCertificateCombinedException(e);