Skip to content

Commit

Permalink
[Mono.Android] ServerCertificateCustomValidationCallback & hostname (#…
Browse files Browse the repository at this point in the history
…7246)

Fixes: #7149

Context: 48540d6
Context: https://docs.microsoft.com/en-us/answers/questions/885827/error-hostname-not-verified-when-sending-request-o.html
Context: dotnet/docs-maui#600
Context: dotnet/maui#8131
Context: dotnet/runtime#70434

Commit 48540d6 added support to
`AndroidMessageHandler.ServerCertificateCustomValidationCallback` for
self-signed certificates.  However, it didn't account for mismatches
between the server's hostname and the certificate's hostname.
This can cause issues when e.g. you use 10.0.2.2 to send requests to
a local server with a self-signed dev certificate in ASP.NET:

	var handler     = new AndroidMessageHandler {
	    // Allow any cert; DO NOT USE IN PRODUCTION
	    ServerCertificateCustomValidationCallback   =
	        (httpRequestMessage, cert, cetChain, policyErrors) => true,
	    // …
	};
	var client      = new HttpClient(handler);
	var response    = await client.SendAsync(message);

This could result an exception similar to:

	System.Net.WebException: Hostname EXAMPLE.DOMAIN not verified:
	    certificate: sha1/EXAMPLE_CERT_SHA1
	    DN: CN=EXAMPLE_CN
	    subjectAltNames: [EXAMPLE_CN]
	---> Javax.Net.Ssl.SSLPeerUnverifiedException: Hostname EXAMPLE.DOMAIN not verified:
	    certificate: sha1/EXAMPLE_CERT_SHA1
	    DN: CN=EXAMPLE_CN
	    subjectAltNames: [EXAMPLE_CN]
	   at Java.Interop.JniEnvironment.InstanceMethods.CallVoidMethod(JniObjectReference instance, JniMethodInfo method, JniArgumentValue* args)
	   at Java.Interop.JniPeerMembers.JniInstanceMethods.InvokeAbstractVoidMethod(String encodedMember, IJavaPeerable self, JniArgumentValue* parameters)
	   at Javax.Net.Ssl.HttpsURLConnectionInvoker.Connect()
	   at Xamarin.Android.Net.AndroidMessageHandler.<>c__DisplayClass125_0.<ConnectAsync>b__0()
	   at System.Threading.Tasks.Task.InnerInvoke()
	   at System.Threading.Tasks.Task.<>c.<.cctor>b__272_0(Object obj)
	   at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state)
	--- End of managed Javax.Net.Ssl.SSLPeerUnverifiedException stack trace ---
	javax.net.ssl.SSLPeerUnverifiedException: Hostname EXAMPLE.DOMAIN not verified:
	    certificate: sha1/EXAMPLE_CERT_SHA1
	    DN: CN=EXAMPLE_CN
	    subjectAltNames: [EXAMPLE_CN]
	     at com.android.okhttp.internal.io.RealConnection.connectTls(RealConnection.java:205)
	     at com.android.okhttp.internal.io.RealConnection.connectSocket(RealConnection.java:153)
	     at com.android.okhttp.internal.io.RealConnection.connect(RealConnection.java:116)
	     at com.android.okhttp.internal.http.StreamAllocation.findConnection(StreamAllocation.java:186)
	     at com.android.okhttp.internal.http.StreamAllocation.findHealthyConnection(StreamAllocation.java:128)
	     at com.android.okhttp.internal.http.StreamAllocation.newStream(StreamAllocation.java:97)
	     at com.android.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:289)
	     at com.android.okhttp.internal.http.HttpEngine.sendRequest(HttpEngine.java:232)
	     at com.android.okhttp.internal.huc.HttpURLConnectionImpl.execute(HttpURLConnectionImpl.java:465)
	     at com.android.okhttp.internal.huc.HttpURLConnectionImpl.connect(HttpURLConnectionImpl.java:131)
	     at com.android.okhttp.internal.huc.DelegatingHttpsURLConnection.connect(DelegatingHttpsURLConnection.java:90)
	     at com.android.okhttp.internal.huc.HttpsURLConnectionImpl.connect(HttpsURLConnectionImpl.java:30)
	--- End of inner exception stack trace ---
	   at Xamarin.Android.Net.AndroidMessageHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
	   at System.Net.Http.HttpClient.<SendAsync>g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken originalCancellationToken)

Address this problem and aligns the behavior of
`AndroidMessageHandler` with other platforms, where hostname
verification is part of the server certificate custom validation
callback.

There are two significant changes:

  - The trust manager that wraps the custom callback now verifies the
    certificate hostname

  - Java's separate hostname verification is skipped

There is only one aspect which is a little hacky: the
[`javax.net.ssl.HostnameVerifier` interface][0] requires an instance
of [`javax.net.ssl.SSLSession`][1], which we don't have access to in
the trust manager.  The default hostname verifier instead uses just a
single getter to access the chain of certificates so it seems OK to me
to have a dummy class that implements the interface that holds just
the peer certificates.

(I tried to find access to the session from `HttpsURLConnection` but
there doesn't seem to be a way to access the `SSLEngine` and its
`HandshakeSesion` property.)

[0]: https://developer.android.com/reference/javax/net/ssl/HostnameVerifier
[1]: https://developer.android.com/reference/javax/net/ssl/SSLSession
  • Loading branch information
simonrozsival authored Sep 8, 2022
1 parent 63b3e0e commit 3d66171
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 122 deletions.
2 changes: 1 addition & 1 deletion src/Mono.Android/Mono.Android.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@
<Compile Include="Xamarin.Android.Net\AuthModuleBasic.cs" />
<Compile Include="Xamarin.Android.Net\AuthModuleDigest.cs" />
<Compile Include="Xamarin.Android.Net\IAndroidAuthenticationModule.cs" />
<Compile Include="Xamarin.Android.Net\X509TrustManagerWithValidationCallback.cs" />
<Compile Include="Xamarin.Android.Net\ServerCertificateCustomValidator.cs" />
<Compile Condition=" '$(TargetFramework)' != 'monoandroid10' " Include="Xamarin.Android.Net\NegotiateAuthenticationHelper.cs" />
<Compile Condition=" '$(TargetFramework)' == 'monoandroid10' " Include="Xamarin.Android.Net\OldAndroidSSLSocketFactory.cs" />
</ItemGroup>
Expand Down
21 changes: 14 additions & 7 deletions src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,19 @@ public CookieContainer CookieContainer

public bool CheckCertificateRevocationList { get; set; } = false;

X509TrustManagerWithValidationCallback.Helper? _callbackTrustManagerHelper = null;
ServerCertificateCustomValidator? _serverCertificateCustomValidator = null;

public Func<HttpRequestMessage, X509Certificate2?, X509Chain?, SslPolicyErrors, bool>? ServerCertificateCustomValidationCallback
{
get => _callbackTrustManagerHelper?.Callback;
get => _serverCertificateCustomValidator?.Callback;
set {
_callbackTrustManagerHelper = value != null ? new X509TrustManagerWithValidationCallback.Helper (value) : null;
if (value is null) {
_serverCertificateCustomValidator = null;
} else if (_serverCertificateCustomValidator is null) {
_serverCertificateCustomValidator = new ServerCertificateCustomValidator (value);
} else {
_serverCertificateCustomValidator.Callback = value;
}
}
}

Expand Down Expand Up @@ -329,7 +335,7 @@ string EncodeUrl (Uri url)
/// <param name="connection">HTTPS connection object.</param>
protected virtual IHostnameVerifier? GetSSLHostnameVerifier (HttpsURLConnection connection)
{
return null;
return _serverCertificateCustomValidator?.HostnameVerifier;
}

internal IHostnameVerifier? GetSSLHostnameVerifierInternal (HttpsURLConnection connection)
Expand Down Expand Up @@ -1069,7 +1075,7 @@ void SetupSSL (HttpsURLConnection? httpsConnection, HttpRequestMessage requestMe
if (tmf == null) {
// If there are no trusted certs, no custom trust manager factory or custom certificate validation callback
// there is no point in changing the behavior of the default SSL socket factory
if (!gotCerts && _callbackTrustManagerHelper == null)
if (!gotCerts && _serverCertificateCustomValidator is null)
return;

tmf = TrustManagerFactory.GetInstance (TrustManagerFactory.DefaultAlgorithm);
Expand All @@ -1078,8 +1084,9 @@ void SetupSSL (HttpsURLConnection? httpsConnection, HttpRequestMessage requestMe

ITrustManager[]? trustManagers = tmf?.GetTrustManagers ();

if (_callbackTrustManagerHelper != null) {
trustManagers = _callbackTrustManagerHelper.Inject (trustManagers, requestMessage);
var customValidator = _serverCertificateCustomValidator;
if (customValidator is not null) {
trustManagers = customValidator.ReplaceX509TrustManager (trustManagers, requestMessage);
}

var context = SSLContext.GetInstance ("TLS");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;

using Javax.Net.Ssl;

using JavaCertificateException = Java.Security.Cert.CertificateException;
using JavaX509Certificate = Java.Security.Cert.X509Certificate;

namespace Xamarin.Android.Net
{
internal sealed class ServerCertificateCustomValidator
{
public IHostnameVerifier HostnameVerifier => AlwaysAcceptingHostnameVerifier.Instance;

public Func<HttpRequestMessage, X509Certificate2?, X509Chain?, SslPolicyErrors, bool> Callback { get; set; }

public ServerCertificateCustomValidator (Func<HttpRequestMessage, X509Certificate2?, X509Chain?, SslPolicyErrors, bool> callback)
{
Callback = callback;
}

public ITrustManager[] ReplaceX509TrustManager (ITrustManager[]? trustManagers, HttpRequestMessage requestMessage)
{
var originalX509TrustManager = FindX509TrustManager(trustManagers);
var trustManagerWithCallback = new TrustManager (originalX509TrustManager, requestMessage, Callback);
return ModifyTrustManagersArray (trustManagers, original: originalX509TrustManager, replacement: trustManagerWithCallback);
}

private sealed class TrustManager : Java.Lang.Object, IX509TrustManager
{
private readonly IX509TrustManager? _internalTrustManager;
private readonly HttpRequestMessage _request;
private readonly Func<HttpRequestMessage, X509Certificate2?, X509Chain?, SslPolicyErrors, bool> _serverCertificateCustomValidationCallback;

public TrustManager (
IX509TrustManager? internalTrustManager,
HttpRequestMessage request,
Func<HttpRequestMessage, X509Certificate2?, X509Chain?, SslPolicyErrors, bool> serverCertificateCustomValidationCallback)
{
_request = request;
_internalTrustManager = internalTrustManager;
_serverCertificateCustomValidationCallback = serverCertificateCustomValidationCallback;
}

public void CheckServerTrusted (JavaX509Certificate[] javaChain, string authType)
{
var sslPolicyErrors = SslPolicyErrors.None;

try {
_internalTrustManager?.CheckServerTrusted (javaChain, authType);
} catch (JavaCertificateException) {
sslPolicyErrors |= SslPolicyErrors.RemoteCertificateChainErrors;
}

var certificates = Convert (javaChain);
X509Certificate2? certificate = null;

if (certificates.Length > 0) {
certificate = certificates [0];
} else {
sslPolicyErrors |= SslPolicyErrors.RemoteCertificateNotAvailable;
}

if (!VerifyHostname (javaChain)) {
sslPolicyErrors |= SslPolicyErrors.RemoteCertificateNameMismatch;
}

if (!_serverCertificateCustomValidationCallback (_request, certificate, CreateChain (certificates), sslPolicyErrors)) {
throw new JavaCertificateException ("The remote certificate was rejected by the provided RemoteCertificateValidationCallback.");
}
}

public void CheckClientTrusted (JavaX509Certificate[] chain, string authType)
=> _internalTrustManager.CheckClientTrusted (chain, authType);

public JavaX509Certificate[] GetAcceptedIssuers ()
=> _internalTrustManager.GetAcceptedIssuers () ?? Array.Empty<JavaX509Certificate> ();

private bool VerifyHostname (JavaX509Certificate[] javaChain)
{
var sslSession = new FakeSSLSession (javaChain);
return HttpsURLConnection.DefaultHostnameVerifier.Verify(_request.RequestUri.Host, sslSession);
}

private static X509Chain CreateChain (X509Certificate2[] certificates)
{
// the chain initialization is based on dotnet/runtime implementation in System.Net.Security.SecureChannel
var chain = new X509Chain ();

chain.ChainPolicy.RevocationMode = X509RevocationMode.Online;
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot;

chain.ChainPolicy.ExtraStore.AddRange (certificates);

return chain;
}

private static X509Certificate2[] Convert (JavaX509Certificate[] certificates)
{
var convertedCertificates = new X509Certificate2 [certificates.Length];
for (int i = 0; i < certificates.Length; i++)
convertedCertificates [i] = new X509Certificate2 (certificates [i].GetEncoded ()!);

return convertedCertificates;
}

// We rely on the fact that the OkHostnameVerifier class that implements the default hostname
// verifier on Android uses the SSLSession object only to get the peer certificates (as of 2022).
// This could change in future Android versions and we would have to implement more methods
// and properties of this interface.
private sealed class FakeSSLSession : Java.Lang.Object, ISSLSession
{
private readonly JavaX509Certificate[] _certificates;

public FakeSSLSession (JavaX509Certificate[] certificates)
{
_certificates = certificates;
}

public Java.Security.Cert.Certificate[] GetPeerCertificates () => _certificates;

public int ApplicationBufferSize => throw new InvalidOperationException ();
public string CipherSuite => throw new InvalidOperationException ();
public long CreationTime => throw new InvalidOperationException ();
public bool IsValid => throw new InvalidOperationException ();
public long LastAccessedTime => throw new InvalidOperationException ();
public Java.Security.IPrincipal LocalPrincipal => throw new InvalidOperationException ();
public int PacketBufferSize => throw new InvalidOperationException ();
public string PeerHost => throw new InvalidOperationException ();
public int PeerPort => throw new InvalidOperationException ();
public Java.Security.IPrincipal PeerPrincipal => throw new InvalidOperationException ();
public string Protocol => throw new InvalidOperationException ();
public ISSLSessionContext SessionContext => throw new InvalidOperationException ();

public byte[] GetId () => throw new InvalidOperationException ();
public Java.Security.Cert.Certificate[] GetLocalCertificates () => throw new InvalidOperationException ();
public Javax.Security.Cert.X509Certificate[] GetPeerCertificateChain () => throw new InvalidOperationException ();
public Java.Lang.Object GetValue(string name) => throw new InvalidOperationException ();
public string[] GetValueNames () => throw new InvalidOperationException ();
public void Invalidate () => throw new InvalidOperationException ();
public void PutValue(string name, Java.Lang.Object value) => throw new InvalidOperationException ();
public void RemoveValue(string name) => throw new InvalidOperationException ();
}
}

// When the hostname verifier is reached, the trust manager has already invoked the
// custom validation callback and approved the remote certificate (including hostname
// mismatch) so at this point there's no verification left to.
private sealed class AlwaysAcceptingHostnameVerifier : Java.Lang.Object, IHostnameVerifier
{
private readonly static Lazy<AlwaysAcceptingHostnameVerifier> s_instance = new Lazy<AlwaysAcceptingHostnameVerifier> (() => new AlwaysAcceptingHostnameVerifier ());

public static AlwaysAcceptingHostnameVerifier Instance => s_instance.Value;

public bool Verify (string? hostname, ISSLSession? session) => true;
}

private static IX509TrustManager? FindX509TrustManager(ITrustManager[] trustManagers)
{
foreach (var trustManager in trustManagers) {
if (trustManager is IX509TrustManager tm)
return tm;
}

return null;
}

private static ITrustManager[] ModifyTrustManagersArray (ITrustManager[] trustManagers, IX509TrustManager? original, IX509TrustManager replacement)
{
var modifiedTrustManagersCount = original is null ? trustManagers.Length + 1 : trustManagers.Length;
var modifiedTrustManagersArray = new ITrustManager [modifiedTrustManagersCount];

modifiedTrustManagersArray [0] = replacement;
int nextIndex = 1;

for (int i = 0; i < trustManagers.Length; i++) {
if (trustManagers [i] == original) {
continue;
}

modifiedTrustManagersArray [nextIndex++] = trustManagers [i];
}

return modifiedTrustManagersArray;
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
-keep class opentk_1_0.platform.android.AndroidGameView { *; <init>(...); }
-keep class opentk_1_0.GameViewBase { *; <init>(...); }
-keep class com.xamarin.java_interop.ManagedPeer { *; <init>(...); }
-keep class xamarin.android.net.X509TrustManagerWithValidationCallback { *; <init>(...); }
-keep class xamarin.android.net.ServerCertificateCustomValidator_TrustManager { *; <init>(...); }
-keep class xamarin.android.net.ServerCertificateCustomValidator_TrustManager_FakeSSLSession { *; <init>(...); }
-keep class xamarin.android.net.ServerCertificateCustomValidator_AlwaysAcceptingHostnameVerifier { *; <init>(...); }

-keep class android.runtime.** { <init>(...); }
-keep class assembly_mono_android.android.runtime.** { <init>(...); }
Expand Down
Loading

0 comments on commit 3d66171

Please sign in to comment.