Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add capability for mutual tls authentication #582

Open
wants to merge 1 commit into
base: v3.X
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/EmbedIO/Net/HttpListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,15 +28,17 @@ public sealed class HttpListener : IHttpListener
/// Initializes a new instance of the <see cref="HttpListener" /> class.
/// </summary>
/// <param name="certificate">The certificate.</param>
public HttpListener(X509Certificate? certificate = null)
/// <param name="clientCertificateValidationCallback">The client certificate validator</param>
public HttpListener(X509Certificate? certificate = null, RemoteCertificateValidationCallback? clientCertificateValidationCallback = null)
{
Certificate = certificate;
ClientCertificateValidationCallback = clientCertificateValidationCallback;

_prefixes = new HttpListenerPrefixCollection(this);
_connections = new ConcurrentDictionary<HttpConnection, object>();
_ctxQueue = new ConcurrentDictionary<string, HttpListenerContext>();
}

/// <inheritdoc />
public bool IgnoreWriteExceptions { get; set; } = true;

Expand All @@ -55,6 +58,8 @@ public HttpListener(X509Certificate? certificate = null)
/// The certificate.
/// </value>
internal X509Certificate? Certificate { get; }

internal RemoteCertificateValidationCallback? ClientCertificateValidationCallback { get; }

/// <inheritdoc />
public void Start()
Expand Down
10 changes: 6 additions & 4 deletions src/EmbedIO/Net/Internal/HttpConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions src/EmbedIO/Net/Internal/HttpListenerRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -90,7 +90,7 @@ public Encoding ContentEncoding
public Stream InputStream => _inputStream ??= ContentLength64 > 0 ? _connection.GetRequestStream(ContentLength64) : Stream.Null;

/// <inheritdoc />
public bool IsAuthenticated => false;
public bool IsAuthenticated => _connection.Stream is SslStream sslStream && sslStream.IsMutuallyAuthenticated;

/// <inheritdoc />
public bool IsLocal => LocalEndPoint.Address?.Equals(RemoteEndPoint.Address) ?? true;
Expand Down
4 changes: 2 additions & 2 deletions src/EmbedIO/WebServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 17 additions & 0 deletions src/EmbedIO/WebServerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,6 +36,8 @@ public sealed class WebServerOptions : WebServerOptionsBase

private StoreLocation _storeLocation = StoreLocation.LocalMachine;

private RemoteCertificateValidationCallback _clientCertificateValidationCallback;

/// <summary>
/// Gets the URL prefixes.
/// </summary>
Expand Down Expand Up @@ -169,6 +172,20 @@ public StoreLocation StoreLocation
}
}

/// <summary>
/// Gets or sets a callback to validate client-side certificates.
/// Client-certificate validation requires SSL to be enabled
/// </summary>
public RemoteCertificateValidationCallback ClientCertificateValidationCallback
{
get => _clientCertificateValidationCallback;
set
{
EnsureConfigurationNotLocked();
_clientCertificateValidationCallback = value;
}
}

/// <summary>
/// Adds a URL prefix.
/// </summary>
Expand Down
16 changes: 16 additions & 0 deletions src/EmbedIO/WebServerOptionsExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using EmbedIO.Utilities;

Expand Down Expand Up @@ -128,6 +129,21 @@ public static WebServerOptions WithCertificate(this WebServerOptions @this, X509
@this.Certificate = value;
return @this;
}

/// <summary>
/// Sets the client certificate validation callback delegate
/// </summary>
/// <param name="this">The <see cref="WebServerOptions"/> on which this method is called.</param>
/// <param name="value">The RemoteCertificateValidationCallback to use for mutual SSL connections.</param>
/// <returns><paramref name="this"/> with its <see cref="WebServerOptions.ClientCertificateValidationCallback">ClientCertificateValidationCallback</see> property
/// set to <paramref name="value"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
public static WebServerOptions WithClientCertificateValidation(this WebServerOptions @this, RemoteCertificateValidationCallback value)
{
@this.ClientCertificateValidationCallback = value;
return @this;
}

/// <summary>
/// Sets the thumbprint of the X.509 certificate to use for SSL connections.
Expand Down
4 changes: 4 additions & 0 deletions test/EmbedIO.Tests/EmbedIO.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@
<ProjectReference Include="..\..\src\EmbedIO.Testing\EmbedIO.Testing.csproj" />
</ItemGroup>

<ItemGroup>
<Content Include="ssl\**" CopyToOutputDirectory="Always" />
</ItemGroup>

</Project>
106 changes: 106 additions & 0 deletions test/EmbedIO.Tests/HttpsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -71,12 +75,114 @@ public void OpenWebServerHttpsWithInvalidStore_ThrowsInvalidOperation()

Assert.Throws<System.Security.Cryptography.CryptographicException>(() => _ = new WebServer(options));
}

/// <summary>
/// Test server with enabled mutual tls authentication for certificate acceptance on both sides
/// </summary>
[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));
}

/// <summary>
/// Test server with enabled mutual tls authentication during a refused mutual authentication on the client side
/// </summary>
[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));
}

/// <summary>
/// Test server with enabled mutual tls authentication when the provided client certificate is not accepted
/// </summary>
[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<HttpRequestException>(async () => await httpClient.GetStringAsync(HttpsUrl));
}

// Bypass certificate validation.
private static bool ValidateCertificate(object sender,
X509Certificate certificate,
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);
}
}
}