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