diff --git a/src/Microsoft.AspNet.Server.Kestrel.Https/ClientCertificateMode.cs b/src/Microsoft.AspNet.Server.Kestrel.Https/ClientCertificateMode.cs new file mode 100644 index 0000000000..34e8b839eb --- /dev/null +++ b/src/Microsoft.AspNet.Server.Kestrel.Https/ClientCertificateMode.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Server.Kestrel.Https +{ + public enum ClientCertificateMode + { + NoCertificate, + AllowCertificate, + RequireCertificate + } +} diff --git a/src/Microsoft.AspNet.Server.Kestrel.Https/ClientCertificateValidationCallback.cs b/src/Microsoft.AspNet.Server.Kestrel.Https/ClientCertificateValidationCallback.cs new file mode 100644 index 0000000000..12616900cc --- /dev/null +++ b/src/Microsoft.AspNet.Server.Kestrel.Https/ClientCertificateValidationCallback.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +namespace Microsoft.AspNet.Server.Kestrel.Https +{ + public delegate bool ClientCertificateValidationCallback( + X509Certificate2 certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors); +} diff --git a/src/Microsoft.AspNet.Server.Kestrel.Https/HttpsApplicationBuilderExtensions.cs b/src/Microsoft.AspNet.Server.Kestrel.Https/HttpsApplicationBuilderExtensions.cs index 844435c152..c595376aa9 100644 --- a/src/Microsoft.AspNet.Server.Kestrel.Https/HttpsApplicationBuilderExtensions.cs +++ b/src/Microsoft.AspNet.Server.Kestrel.Https/HttpsApplicationBuilderExtensions.cs @@ -11,6 +11,11 @@ namespace Microsoft.AspNet.Server.Kestrel.Https public static class HttpsApplicationBuilderExtensions { public static IApplicationBuilder UseKestrelHttps(this IApplicationBuilder app, X509Certificate2 cert) + { + return app.UseKestrelHttps(new HttpsConnectionFilterOptions { ServerCertificate = cert}); + } + + public static IApplicationBuilder UseKestrelHttps(this IApplicationBuilder app, HttpsConnectionFilterOptions options) { var serverInfo = app.ServerFeatures.Get(); @@ -21,7 +26,7 @@ namespace Microsoft.AspNet.Server.Kestrel.Https var prevFilter = serverInfo.ConnectionFilter ?? new NoOpConnectionFilter(); - serverInfo.ConnectionFilter = new HttpsConnectionFilter(cert, prevFilter); + serverInfo.ConnectionFilter = new HttpsConnectionFilter(options, prevFilter); return app; } diff --git a/src/Microsoft.AspNet.Server.Kestrel.Https/HttpsConnectionFilter.cs b/src/Microsoft.AspNet.Server.Kestrel.Https/HttpsConnectionFilter.cs index 08cad7161d..e66fa4b3c5 100644 --- a/src/Microsoft.AspNet.Server.Kestrel.Https/HttpsConnectionFilter.cs +++ b/src/Microsoft.AspNet.Server.Kestrel.Https/HttpsConnectionFilter.cs @@ -3,29 +3,37 @@ using System; using System.Net.Security; +using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; +using Microsoft.AspNet.Http.Features; +using Microsoft.AspNet.Http.Features.Internal; using Microsoft.AspNet.Server.Kestrel.Filter; namespace Microsoft.AspNet.Server.Kestrel.Https { public class HttpsConnectionFilter : IConnectionFilter { - private readonly X509Certificate2 _cert; + private readonly X509Certificate2 _serverCert; + private readonly ClientCertificateMode _clientCertMode; + private readonly ClientCertificateValidationCallback _clientValidationCallback; private readonly IConnectionFilter _previous; + private X509Certificate2 _clientCert; - public HttpsConnectionFilter(X509Certificate2 cert, IConnectionFilter previous) + public HttpsConnectionFilter(HttpsConnectionFilterOptions options, IConnectionFilter previous) { - if (cert == null) + if (options.ServerCertificate == null) { - throw new ArgumentNullException(nameof(cert)); + throw new ArgumentNullException(nameof(options.ServerCertificate)); } if (previous == null) { throw new ArgumentNullException(nameof(previous)); } - _cert = cert; + _serverCert = options.ServerCertificate; + _clientCertMode = options.ClientCertificateMode; + _clientValidationCallback = options.ClientCertificateValidation; _previous = previous; } @@ -35,10 +43,68 @@ namespace Microsoft.AspNet.Server.Kestrel.Https if (string.Equals(context.Address.Scheme, "https", StringComparison.OrdinalIgnoreCase)) { - var sslStream = new SslStream(context.Connection); - await sslStream.AuthenticateAsServerAsync(_cert); + SslStream sslStream; + if (_clientCertMode == ClientCertificateMode.NoCertificate) + { + sslStream = new SslStream(context.Connection); + await sslStream.AuthenticateAsServerAsync(_serverCert); + } + else + { + sslStream = new SslStream(context.Connection, leaveInnerStreamOpen: false, + userCertificateValidationCallback: (sender, certificate, chain, sslPolicyErrors) => + { + if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNotAvailable)) + { + return _clientCertMode != ClientCertificateMode.RequireCertificate; + } + + + if (_clientValidationCallback == null) + { + if (sslPolicyErrors != SslPolicyErrors.None) + { + return false; + } + } +#if DOTNET5_4 + // conversion X509Certificate to X509Certificate2 not supported + // https://github.com/dotnet/corefx/issues/4510 + X509Certificate2 certificate2 = null; + return false; +#else + X509Certificate2 certificate2 = certificate as X509Certificate2 ?? + new X509Certificate2(certificate); + +#endif + if (_clientValidationCallback != null) + { + if (!_clientValidationCallback(certificate2, chain, sslPolicyErrors)) + { + return false; + } + } + + _clientCert = certificate2; + return true; + }); + await sslStream.AuthenticateAsServerAsync(_serverCert, clientCertificateRequired: true, + enabledSslProtocols: SslProtocols.Tls12 | SslProtocols.Tls11 | SslProtocols.Tls, + checkCertificateRevocation: false); + } context.Connection = sslStream; } } + + public void PrepareRequest(IFeatureCollection features) + { + _previous.PrepareRequest(features); + + if (_clientCert != null) + { + features.Set( + new TlsConnectionFeature { ClientCertificate = _clientCert }); + } + } } } diff --git a/src/Microsoft.AspNet.Server.Kestrel.Https/HttpsConnectionFilterOptions.cs b/src/Microsoft.AspNet.Server.Kestrel.Https/HttpsConnectionFilterOptions.cs new file mode 100644 index 0000000000..62c513c216 --- /dev/null +++ b/src/Microsoft.AspNet.Server.Kestrel.Https/HttpsConnectionFilterOptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Security.Cryptography.X509Certificates; + +namespace Microsoft.AspNet.Server.Kestrel.Https +{ + public class HttpsConnectionFilterOptions + { + public HttpsConnectionFilterOptions() + { + ClientCertificateMode = ClientCertificateMode.NoCertificate; + } + + public X509Certificate2 ServerCertificate { get; set; } + public ClientCertificateMode ClientCertificateMode { get; set; } + public ClientCertificateValidationCallback ClientCertificateValidation { get; set; } + } +} diff --git a/src/Microsoft.AspNet.Server.Kestrel/Filter/ConnectionFilterContext.cs b/src/Microsoft.AspNet.Server.Kestrel/Filter/ConnectionFilterContext.cs index 937e92bec3..278ca58254 100644 --- a/src/Microsoft.AspNet.Server.Kestrel/Filter/ConnectionFilterContext.cs +++ b/src/Microsoft.AspNet.Server.Kestrel/Filter/ConnectionFilterContext.cs @@ -2,12 +2,13 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.IO; +using Microsoft.AspNet.Http.Features; namespace Microsoft.AspNet.Server.Kestrel.Filter { public class ConnectionFilterContext { public ServerAddress Address { get; set; } - public Stream Connection { get; set; } + public Stream Connection { get; set; } } } diff --git a/src/Microsoft.AspNet.Server.Kestrel/Filter/IConnectionFilter.cs b/src/Microsoft.AspNet.Server.Kestrel/Filter/IConnectionFilter.cs index accaa3b9d9..8c4f827be9 100644 --- a/src/Microsoft.AspNet.Server.Kestrel/Filter/IConnectionFilter.cs +++ b/src/Microsoft.AspNet.Server.Kestrel/Filter/IConnectionFilter.cs @@ -2,11 +2,13 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Threading.Tasks; +using Microsoft.AspNet.Http.Features; namespace Microsoft.AspNet.Server.Kestrel.Filter { public interface IConnectionFilter { Task OnConnection(ConnectionFilterContext context); + void PrepareRequest(IFeatureCollection features); } } diff --git a/src/Microsoft.AspNet.Server.Kestrel/Filter/NoOpConnectionFilter.cs b/src/Microsoft.AspNet.Server.Kestrel/Filter/NoOpConnectionFilter.cs index b1217d3034..aac76ec34e 100644 --- a/src/Microsoft.AspNet.Server.Kestrel/Filter/NoOpConnectionFilter.cs +++ b/src/Microsoft.AspNet.Server.Kestrel/Filter/NoOpConnectionFilter.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Threading.Tasks; +using Microsoft.AspNet.Http.Features; using Microsoft.AspNet.Server.Kestrel.Infrastructure; namespace Microsoft.AspNet.Server.Kestrel.Filter @@ -12,5 +13,8 @@ namespace Microsoft.AspNet.Server.Kestrel.Filter { return TaskUtilities.CompletedTask; } + + public void PrepareRequest(IFeatureCollection features) + {} } } diff --git a/src/Microsoft.AspNet.Server.Kestrel/Http/Connection.cs b/src/Microsoft.AspNet.Server.Kestrel/Http/Connection.cs index 4e95d3d1ca..42d8aeb30a 100644 --- a/src/Microsoft.AspNet.Server.Kestrel/Http/Connection.cs +++ b/src/Microsoft.AspNet.Server.Kestrel/Http/Connection.cs @@ -177,7 +177,7 @@ namespace Microsoft.AspNet.Server.Kestrel.Http private Frame CreateFrame() { - return new Frame(this, _remoteEndPoint, _localEndPoint); + return new Frame(this, _remoteEndPoint, _localEndPoint, ConnectionFilter); } void IConnectionControl.Pause() diff --git a/src/Microsoft.AspNet.Server.Kestrel/Http/Frame.cs b/src/Microsoft.AspNet.Server.Kestrel/Http/Frame.cs index 4b889c710e..0edaeb3fee 100644 --- a/src/Microsoft.AspNet.Server.Kestrel/Http/Frame.cs +++ b/src/Microsoft.AspNet.Server.Kestrel/Http/Frame.cs @@ -11,6 +11,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNet.Http; using Microsoft.AspNet.Http.Features; +using Microsoft.AspNet.Server.Kestrel.Filter; using Microsoft.AspNet.Server.Kestrel.Infrastructure; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; @@ -55,19 +56,22 @@ namespace Microsoft.AspNet.Server.Kestrel.Http private readonly IPEndPoint _localEndPoint; private readonly IPEndPoint _remoteEndPoint; + private readonly IConnectionFilter _connectionFilter; public Frame(ConnectionContext context) - : this(context, remoteEndPoint: null, localEndPoint: null) + : this(context, remoteEndPoint: null, localEndPoint: null, connectionFilter: null) { } public Frame(ConnectionContext context, IPEndPoint remoteEndPoint, - IPEndPoint localEndPoint) + IPEndPoint localEndPoint, + IConnectionFilter connectionFilter) : base(context) { _remoteEndPoint = remoteEndPoint; _localEndPoint = localEndPoint; + _connectionFilter = connectionFilter; FrameControl = this; Reset(); @@ -140,6 +144,8 @@ namespace Microsoft.AspNet.Server.Kestrel.Http httpConnectionFeature.IsLocal = false; } + _connectionFilter?.PrepareRequest(this); + _requestAbortCts?.Dispose(); } @@ -272,7 +278,7 @@ namespace Microsoft.AspNet.Server.Kestrel.Http // If _requestAbort is set, the connection has already been closed. if (!_requestAborted) { - await ProduceEnd(); + await ProduceEnd(); // Finish reading the request body in case the app did not. await messageBody.Consume(); @@ -301,15 +307,15 @@ namespace Microsoft.AspNet.Server.Kestrel.Http // If _requestAborted is set, the connection has already been closed. if (!_requestAborted) { - // Inform client no more data will ever arrive - ConnectionControl.End(ProduceEndType.SocketShutdownSend); + // Inform client no more data will ever arrive + ConnectionControl.End(ProduceEndType.SocketShutdownSend); - // Wait for client to either disconnect or send unexpected data - await SocketInput; + // Wait for client to either disconnect or send unexpected data + await SocketInput; - // Dispose socket - ConnectionControl.End(ProduceEndType.SocketDisconnect); - } + // Dispose socket + ConnectionControl.End(ProduceEndType.SocketDisconnect); + } } catch (Exception ex) { diff --git a/test/Microsoft.AspNet.Server.KestrelTests/ConnectionFilterTests.cs b/test/Microsoft.AspNet.Server.KestrelTests/ConnectionFilterTests.cs index 5842e1d7c0..0af98798cb 100644 --- a/test/Microsoft.AspNet.Server.KestrelTests/ConnectionFilterTests.cs +++ b/test/Microsoft.AspNet.Server.KestrelTests/ConnectionFilterTests.cs @@ -95,6 +95,9 @@ namespace Microsoft.AspNet.Server.KestrelTests return _empty; } + public void PrepareRequest(IFeatureCollection frame) + {} + public int BytesRead => _rewritingStream.BytesRead; } @@ -110,6 +113,9 @@ namespace Microsoft.AspNet.Server.KestrelTests context.Connection = new RewritingStream(oldConnection); } + + public void PrepareRequest(IFeatureCollection frame) + {} } private class RewritingStream : Stream diff --git a/test/Microsoft.AspNet.Server.KestrelTests/HttpsConnectionFilterTests.cs b/test/Microsoft.AspNet.Server.KestrelTests/HttpsConnectionFilterTests.cs index cf68bd89b1..b738d97131 100644 --- a/test/Microsoft.AspNet.Server.KestrelTests/HttpsConnectionFilterTests.cs +++ b/test/Microsoft.AspNet.Server.KestrelTests/HttpsConnectionFilterTests.cs @@ -3,12 +3,16 @@ using System; using System.Collections.Generic; +using System.IO; using System.Net; using System.Net.Http; using System.Net.Security; +using System.Net.Sockets; using System.Security.Cryptography.X509Certificates; +using System.Text; using System.Threading.Tasks; using Microsoft.AspNet.Http; +using Microsoft.AspNet.Http.Features; using Microsoft.AspNet.Server.Kestrel.Filter; using Microsoft.AspNet.Server.Kestrel.Https; using Microsoft.AspNet.Testing.xunit; @@ -55,19 +59,20 @@ namespace Microsoft.AspNet.Server.KestrelTests handler.ServerCertificateValidationCallback += (sender, cert, chain, sslPolicyErrors) => true; #endif - var sereverAddress = "https://localhost:54321/"; + var serverAddress = "https://localhost:54321/"; var serviceContext = new TestServiceContext() { ConnectionFilter = new HttpsConnectionFilter( - new X509Certificate2(@"TestResources/testCert.pfx", "testPassword"), + new HttpsConnectionFilterOptions + { ServerCertificate = new X509Certificate2(@"TestResources/testCert.pfx", "testPassword")}, new NoOpConnectionFilter()) }; - using (var server = new TestServer(App, serviceContext, sereverAddress)) + using (var server = new TestServer(App, serviceContext, serverAddress)) { using (var client = new HttpClient(handler)) { - var result = await client.PostAsync(sereverAddress, new FormUrlEncodedContent(new[] { + var result = await client.PostAsync(serverAddress, new FormUrlEncodedContent(new[] { new KeyValuePair("content", "Hello World?") })); @@ -82,5 +87,189 @@ namespace Microsoft.AspNet.Server.KestrelTests #endif } } + + // https://github.com/aspnet/KestrelHttpServer/issues/240 + // This test currently fails on mono because of an issue with SslStream. + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux)] + [OSSkipCondition(OperatingSystems.MacOSX)] + public async Task RequireCertificateFailsWhenNoCertificate() + { + RemoteCertificateValidationCallback validationCallback = + (sender, cert, chain, sslPolicyErrors) => true; + + try + { +#if DNX451 + var handler = new HttpClientHandler(); + ServicePointManager.ServerCertificateValidationCallback += validationCallback; +#else + var handler = new WinHttpHandler(); + handler.ServerCertificateValidationCallback += (sender, cert, chain, sslPolicyErrors) => true; +#endif + + var serverAddress = "https://localhost:54321/"; + var serviceContext = new TestServiceContext() + { + ConnectionFilter = new HttpsConnectionFilter( + new HttpsConnectionFilterOptions + { + ServerCertificate = new X509Certificate2(@"TestResources/testCert.pfx", "testPassword"), + ClientCertificateMode = ClientCertificateMode.RequireCertificate + }, + new NoOpConnectionFilter()) + }; + + using (var server = new TestServer(App, serviceContext, serverAddress)) + { + using (var client = new HttpClient()) + { + await Assert.ThrowsAnyAsync( + () => client.GetAsync(serverAddress)); + } + } + } + finally + { +#if DNX451 + ServicePointManager.ServerCertificateValidationCallback -= validationCallback; +#endif + } + } + + // https://github.com/dotnet/corefx/issues/4512 + // WinHttpHandler throws an Exception (ERROR_INTERNET_SECURE_FAILURE) +#if DNX451 + + // https://github.com/aspnet/KestrelHttpServer/issues/240 + // This test currently fails on mono because of an issue with SslStream. + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux)] + [OSSkipCondition(OperatingSystems.MacOSX)] + public async Task AllowCertificateContinuesWhenNoCertificate() + { + RemoteCertificateValidationCallback validationCallback = + (sender, cert, chain, sslPolicyErrors) => true; + + try + { +#if DNX451 + var handler = new HttpClientHandler(); + ServicePointManager.ServerCertificateValidationCallback += validationCallback; +#else + var handler = new WinHttpHandler(); + handler.ServerCertificateValidationCallback += (sender, cert, chain, sslPolicyErrors) => true; +#endif + + var serverAddress = "https://localhost:54321/"; + var serviceContext = new TestServiceContext() + { + ConnectionFilter = new HttpsConnectionFilter( + new HttpsConnectionFilterOptions + { + ServerCertificate = new X509Certificate2(@"TestResources/testCert.pfx", "testPassword"), + ClientCertificateMode = ClientCertificateMode.AllowCertificate + }, + new NoOpConnectionFilter()) + }; + + RequestDelegate app = context => + { + Assert.Equal(context.Features.Get(), null); + return context.Response.WriteAsync("hello world"); + }; + + using (var server = new TestServer(app, serviceContext, serverAddress)) + { + using (var client = new HttpClient()) + { + var result = await client.GetAsync(serverAddress); + + Assert.Equal("hello world", await result.Content.ReadAsStringAsync()); + } + } + } + finally + { +#if DNX451 + ServicePointManager.ServerCertificateValidationCallback -= validationCallback; +#endif + } + } +#endif + + // https://github.com/dotnet/corefx/issues/4510 + // Can't convert X509Certificate to X509Certificate2 in HttpsConnectionFilter +#if DNX451 + + // https://github.com/aspnet/KestrelHttpServer/issues/240 + // This test currently fails on mono because of an issue with SslStream. + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux)] + [OSSkipCondition(OperatingSystems.MacOSX)] + public async Task CertificatePassedToHttpContext() + { + RemoteCertificateValidationCallback validationCallback = + (sender, cert, chain, sslPolicyErrors) => true; + + try + { +#if DNX451 + ServicePointManager.ServerCertificateValidationCallback += validationCallback; +#endif + + var serverAddress = "https://localhost:54321/"; + var serviceContext = new TestServiceContext() + { + ConnectionFilter = new HttpsConnectionFilter( + new HttpsConnectionFilterOptions + { + ServerCertificate = new X509Certificate2(@"TestResources/testCert.pfx", "testPassword"), + ClientCertificateMode = ClientCertificateMode.RequireCertificate, + ClientCertificateValidation = (certificate, chain, sslPolicyErrors) => true + }, + new NoOpConnectionFilter()) + }; + + RequestDelegate app = context => + { + var tlsFeature = context.Features.Get(); + Assert.NotNull(tlsFeature); + Assert.NotNull(tlsFeature.ClientCertificate); + Assert.NotNull(context.Connection.ClientCertificate); + return context.Response.WriteAsync("hello world"); + }; + + using (var server = new TestServer(app, serviceContext, serverAddress)) + { + // SslStream is used to ensure the certificate is actually passed to the server + // HttpClient might not send the certificate because it is invalid or it doesn't match any + // of the certificate authorities sent by the server in the SSL handshake. + using (var client = new TcpClient()) + { + await client.ConnectAsync("127.0.0.1", 54321); + + SslStream stream = new SslStream(client.GetStream(), false, (sender, certificate, chain, errors) => true, + (sender, host, certificates, certificate, issuers) => new X509Certificate2(@"TestResources/testCert.pfx", "testPassword")); + await stream.AuthenticateAsClientAsync("localhost"); + + var request = Encoding.UTF8.GetBytes("GET / HTTP/1.0\r\n\r\n"); + await stream.WriteAsync(request, 0, request.Length); + + var reader = new StreamReader(stream); + var line = await reader.ReadLineAsync(); + Assert.Equal("HTTP/1.0 200 OK", line); + } + } + } + finally + { +#if DNX451 + ServicePointManager.ServerCertificateValidationCallback -= validationCallback; +#endif + } +} +#endif + } }