From 43b8ded7c56f4182dcb390f68b22af08ae0736ac Mon Sep 17 00:00:00 2001 From: Beat Durrer <508289+bdurrer@users.noreply.github.com> Date: Mon, 5 Jun 2023 22:08:37 +0200 Subject: [PATCH] Add capability for mutual tls authentication --- src/EmbedIO/Net/HttpListener.cs | 9 +- src/EmbedIO/Net/Internal/HttpConnection.cs | 10 +- .../Net/Internal/HttpListenerRequest.cs | 4 +- src/EmbedIO/WebServer.cs | 4 +- src/EmbedIO/WebServerOptions.cs | 17 +++ src/EmbedIO/WebServerOptionsExtensions.cs | 16 +++ test/EmbedIO.Tests/EmbedIO.Tests.csproj | 4 + test/EmbedIO.Tests/HttpsTest.cs | 106 ++++++++++++++++++ 8 files changed, 160 insertions(+), 10 deletions(-) diff --git a/src/EmbedIO/Net/HttpListener.cs b/src/EmbedIO/Net/HttpListener.cs index 2f96ffb20..e095f942a 100644 --- a/src/EmbedIO/Net/HttpListener.cs +++ b/src/EmbedIO/Net/HttpListener.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Net.Security; using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; @@ -27,15 +28,17 @@ public sealed class HttpListener : IHttpListener /// Initializes a new instance of the class. /// /// The certificate. - public HttpListener(X509Certificate? certificate = null) + /// The client certificate validator + public HttpListener(X509Certificate? certificate = null, RemoteCertificateValidationCallback? clientCertificateValidationCallback = null) { Certificate = certificate; + ClientCertificateValidationCallback = clientCertificateValidationCallback; _prefixes = new HttpListenerPrefixCollection(this); _connections = new ConcurrentDictionary(); _ctxQueue = new ConcurrentDictionary(); } - + /// public bool IgnoreWriteExceptions { get; set; } = true; @@ -55,6 +58,8 @@ public HttpListener(X509Certificate? certificate = null) /// The certificate. /// internal X509Certificate? Certificate { get; } + + internal RemoteCertificateValidationCallback? ClientCertificateValidationCallback { get; } /// public void Start() diff --git a/src/EmbedIO/Net/Internal/HttpConnection.cs b/src/EmbedIO/Net/Internal/HttpConnection.cs index 230a46de6..3ce2fe527 100644 --- a/src/EmbedIO/Net/Internal/HttpConnection.cs +++ b/src/EmbedIO/Net/Internal/HttpConnection.cs @@ -3,7 +3,7 @@ using System.Net; using System.Net.Security; using System.Net.Sockets; -using System.Security.Cryptography.X509Certificates; +using System.Security.Authentication; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -42,14 +42,16 @@ public HttpConnection(Socket sock, EndPointListener epl) Stream = new NetworkStream(sock, false); if (IsSecure) { - var sslStream = new SslStream(Stream, true); + var sslStream = new SslStream(Stream, true, epl.Listener.ClientCertificateValidationCallback); try { - sslStream.AuthenticateAsServer(epl.Listener.Certificate); + var checkClientCertificate = epl.Listener.ClientCertificateValidationCallback != null; + sslStream.AuthenticateAsServer(epl.Listener.Certificate, checkClientCertificate, SslProtocols.None, false); } - catch + catch (Exception e) { + Console.Error.WriteLine(e); CloseSocket(); throw; } diff --git a/src/EmbedIO/Net/Internal/HttpListenerRequest.cs b/src/EmbedIO/Net/Internal/HttpListenerRequest.cs index 548f44d73..420dd4669 100644 --- a/src/EmbedIO/Net/Internal/HttpListenerRequest.cs +++ b/src/EmbedIO/Net/Internal/HttpListenerRequest.cs @@ -4,7 +4,7 @@ using System.IO; using System.Linq; using System.Net; -using System.Security.Cryptography.X509Certificates; +using System.Net.Security; using System.Text; using EmbedIO.Internal; using EmbedIO.Utilities; @@ -90,7 +90,7 @@ public Encoding ContentEncoding public Stream InputStream => _inputStream ??= ContentLength64 > 0 ? _connection.GetRequestStream(ContentLength64) : Stream.Null; /// - public bool IsAuthenticated => false; + public bool IsAuthenticated => _connection.Stream is SslStream sslStream && sslStream.IsMutuallyAuthenticated; /// public bool IsLocal => LocalEndPoint.Address?.Equals(RemoteEndPoint.Address) ?? true; diff --git a/src/EmbedIO/WebServer.cs b/src/EmbedIO/WebServer.cs index f68382d9a..db1790d29 100644 --- a/src/EmbedIO/WebServer.cs +++ b/src/EmbedIO/WebServer.cs @@ -167,8 +167,8 @@ private IHttpListener CreateHttpListener() IHttpListener DoCreate() => Options.Mode switch { HttpListenerMode.Microsoft => System.Net.HttpListener.IsSupported ? new SystemHttpListener(new System.Net.HttpListener()) as IHttpListener - : new Net.HttpListener(Options.Certificate), - _ => new Net.HttpListener(Options.Certificate) + : new Net.HttpListener(Options.Certificate, Options.ClientCertificateValidationCallback), + _ => new Net.HttpListener(Options.Certificate, Options.ClientCertificateValidationCallback) }; var listener = DoCreate(); diff --git a/src/EmbedIO/WebServerOptions.cs b/src/EmbedIO/WebServerOptions.cs index 2700a9f59..c797a618b 100644 --- a/src/EmbedIO/WebServerOptions.cs +++ b/src/EmbedIO/WebServerOptions.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Globalization; using System.Linq; +using System.Net.Security; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.RegularExpressions; @@ -35,6 +36,8 @@ public sealed class WebServerOptions : WebServerOptionsBase private StoreLocation _storeLocation = StoreLocation.LocalMachine; + private RemoteCertificateValidationCallback _clientCertificateValidationCallback; + /// /// Gets the URL prefixes. /// @@ -169,6 +172,20 @@ public StoreLocation StoreLocation } } + /// + /// Gets or sets a callback to validate client-side certificates. + /// Client-certificate validation requires SSL to be enabled + /// + public RemoteCertificateValidationCallback ClientCertificateValidationCallback + { + get => _clientCertificateValidationCallback; + set + { + EnsureConfigurationNotLocked(); + _clientCertificateValidationCallback = value; + } + } + /// /// Adds a URL prefix. /// diff --git a/src/EmbedIO/WebServerOptionsExtensions.cs b/src/EmbedIO/WebServerOptionsExtensions.cs index b68b28d8c..454e4d664 100644 --- a/src/EmbedIO/WebServerOptionsExtensions.cs +++ b/src/EmbedIO/WebServerOptionsExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net.Security; using System.Security.Cryptography.X509Certificates; using EmbedIO.Utilities; @@ -128,6 +129,21 @@ public static WebServerOptions WithCertificate(this WebServerOptions @this, X509 @this.Certificate = value; return @this; } + + /// + /// Sets the client certificate validation callback delegate + /// + /// The on which this method is called. + /// The RemoteCertificateValidationCallback to use for mutual SSL connections. + /// with its ClientCertificateValidationCallback property + /// set to . + /// is . + /// The configuration of is locked. + public static WebServerOptions WithClientCertificateValidation(this WebServerOptions @this, RemoteCertificateValidationCallback value) + { + @this.ClientCertificateValidationCallback = value; + return @this; + } /// /// Sets the thumbprint of the X.509 certificate to use for SSL connections. diff --git a/test/EmbedIO.Tests/EmbedIO.Tests.csproj b/test/EmbedIO.Tests/EmbedIO.Tests.csproj index 1ad58b4a1..afbf7fb65 100644 --- a/test/EmbedIO.Tests/EmbedIO.Tests.csproj +++ b/test/EmbedIO.Tests/EmbedIO.Tests.csproj @@ -22,4 +22,8 @@ + + + + diff --git a/test/EmbedIO.Tests/HttpsTest.cs b/test/EmbedIO.Tests/HttpsTest.cs index 04b738d2a..f4631a663 100644 --- a/test/EmbedIO.Tests/HttpsTest.cs +++ b/test/EmbedIO.Tests/HttpsTest.cs @@ -15,6 +15,10 @@ public class HttpsTest private const string DefaultMessage = "HOLA"; private const string HttpsUrl = "https://localhost:5555"; + private const string CLIENT_ONE_CERT_HASH = "7b49b5d24ea5ae856572fd4f103ffbc4443399ce"; + private const string CLIENT_TWO_CERT_HASH = "a463dcf86fb1daac02a86cda5f9fea7579b8fffb"; + private const string SERVER_CERT_HASH = "435bb9b0dd3b167c9db218572a6817d01b86f45b"; + [Test] [Platform("Win")] public async Task OpenWebServerHttps_RetrievesIndex() @@ -71,6 +75,103 @@ public void OpenWebServerHttpsWithInvalidStore_ThrowsInvalidOperation() Assert.Throws(() => _ = new WebServer(options)); } + + /// + /// Test server with enabled mutual tls authentication for certificate acceptance on both sides + /// + [Test] + [Platform("Win")] + public async Task OpenWebServerHttpsWithClientCertificate_AcceptsKnownCertificate() + { + var options = new WebServerOptions() + .WithUrlPrefix(HttpsUrl) + .WithCertificate(new X509Certificate2(@".\ssl\server.pfx", "embedio")) + .WithMode(HttpListenerMode.EmbedIO) + .WithClientCertificateValidation( + (sender, certificate, chain, errors) => + certificate == null || CLIENT_ONE_CERT_HASH.Equals(certificate.GetCertHashString(), StringComparison.OrdinalIgnoreCase)); + + using var webServer = new WebServer(options); + webServer.OnAny(ctx => { + Assert.True(ctx.Request.IsAuthenticated, "User is authenticated"); + return ctx.SendStringAsync(DefaultMessage, MimeType.PlainText, WebServer.DefaultEncoding); + }); + + _ = webServer.RunAsync(); + + using var httpClientHandler = new HttpClientHandler { + ServerCertificateCustomValidationCallback = ValidateFixedServerCertificate, + ClientCertificateOptions = ClientCertificateOption.Manual, + ClientCertificates = { new X509Certificate2(@"ssl/client1.pfx", "embedio") } + }; + using var httpClient = new HttpClient(httpClientHandler); + Assert.AreEqual(DefaultMessage, await httpClient.GetStringAsync(HttpsUrl)); + } + + /// + /// Test server with enabled mutual tls authentication during a refused mutual authentication on the client side + /// + [Test] + [Platform("Win")] + public async Task OpenWebServerHttpsWithClientCertificate_CanAcceptAnon() + { + var options = new WebServerOptions() + .WithUrlPrefix(HttpsUrl) + .WithCertificate(new X509Certificate2(@".\ssl\server.pfx", "embedio")) + .WithMode(HttpListenerMode.EmbedIO) + // by treating an missing certificate as okay, we can allow anon requests + .WithClientCertificateValidation( + (sender, certificate, chain, errors) => + certificate == null || CLIENT_ONE_CERT_HASH.Equals(certificate.GetCertHashString(), StringComparison.OrdinalIgnoreCase)); + + using var webServer = new WebServer(options); + webServer.OnAny(ctx => { + // The user did not provide any certificate and thus is treated as anonymous + Assert.False(ctx.Request.IsAuthenticated, "User is authenticated"); + return ctx.SendStringAsync(DefaultMessage, MimeType.PlainText, WebServer.DefaultEncoding); + }); + + _ = webServer.RunAsync(); + + using var httpClientHandler = new HttpClientHandler { + ServerCertificateCustomValidationCallback = ValidateFixedServerCertificate + }; + using var httpClient = new HttpClient(httpClientHandler); + Assert.AreEqual(DefaultMessage, await httpClient.GetStringAsync(HttpsUrl)); + } + + /// + /// Test server with enabled mutual tls authentication when the provided client certificate is not accepted + /// + [Test] + [Platform("Win")] + public async Task OpenWebServerHttpsWithClientCertificate_RejectsUnknownCert() + { + var options = new WebServerOptions() + .WithUrlPrefix(HttpsUrl) + .WithCertificate(new X509Certificate2(@".\ssl\server.pfx", "embedio")) + .WithMode(HttpListenerMode.EmbedIO) + // refuse all certificates to make client certificate validation fail + .WithClientCertificateValidation( + (sender, certificate, chain, errors) => false); + + using var webServer = new WebServer(options); + webServer.OnAny(ctx => { + // The user did not provide any certificate and thus is treated as anonymous + Assert.Fail("Server should refuse service"); + return ctx.SendStringAsync(DefaultMessage, MimeType.PlainText, WebServer.DefaultEncoding); + }); + + _ = webServer.RunAsync(); + + using var httpClientHandler = new HttpClientHandler { + ServerCertificateCustomValidationCallback = ValidateFixedServerCertificate, + ClientCertificateOptions = ClientCertificateOption.Manual, + ClientCertificates = { new X509Certificate2(@"ssl/client2.pfx", "embedio") } + }; + using var httpClient = new HttpClient(httpClientHandler); + Assert.ThrowsAsync(async () => await httpClient.GetStringAsync(HttpsUrl)); + } // Bypass certificate validation. private static bool ValidateCertificate(object sender, @@ -78,5 +179,10 @@ private static bool ValidateCertificate(object sender, X509Chain chain, SslPolicyErrors sslPolicyErrors) => true; + + private static bool ValidateFixedServerCertificate(HttpRequestMessage message, X509Certificate2? certificate, X509Chain? chain, SslPolicyErrors errors) + { + return certificate != null && SERVER_CERT_HASH.Equals(certificate.GetCertHashString(), StringComparison.OrdinalIgnoreCase); + } } } \ No newline at end of file