diff --git a/src/Http/Routing/src/Matching/HostMatcherPolicy.cs b/src/Http/Routing/src/Matching/HostMatcherPolicy.cs index d0f29612e469..2a3047489ebf 100644 --- a/src/Http/Routing/src/Matching/HostMatcherPolicy.cs +++ b/src/Http/Routing/src/Matching/HostMatcherPolicy.cs @@ -118,7 +118,7 @@ public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) else if ( host.StartsWith(WildcardPrefix) && - // Note that we only slice of the `*`. We want to match the leading `.` also. + // Note that we only slice off the `*`. We want to match the leading `.` also. MemoryExtensions.EndsWith(requestHost, host.Slice(WildcardHost.Length), StringComparison.OrdinalIgnoreCase)) { // Matches a suffix wildcard. diff --git a/src/Servers/Kestrel/Core/src/CoreStrings.resx b/src/Servers/Kestrel/Core/src/CoreStrings.resx index 49c2f5fb482c..1dbd5c043564 100644 --- a/src/Servers/Kestrel/Core/src/CoreStrings.resx +++ b/src/Servers/Kestrel/Core/src/CoreStrings.resx @@ -620,4 +620,16 @@ For more information on configuring HTTPS see https://go.microsoft.com/fwlink/?l Unknown algorithm for certificate with public key type '{0}'. + + Connection refused because no SNI configuration section was found for '{serverName}' in '{endpointName}'. To allow all connections, add a wildcard ('*') SNI section. + + + Connection refused because the client did not specify a server name, and no wildcard ('*') SNI configuration section was found in '{endpointName}'. + + + The endpoint {endpointName} is invalid because an SNI configuration section has an empty string as its key. Use a wildcard ('*') SNI section to match all server names. + + + The non-HTTPS endpoint {endpointName} includes HTTPS-only configuration for {keyName}. + \ No newline at end of file diff --git a/src/Servers/Kestrel/Core/src/HttpsConnectionAdapterOptions.cs b/src/Servers/Kestrel/Core/src/HttpsConnectionAdapterOptions.cs index d801316b5f9c..9961101484ca 100644 --- a/src/Servers/Kestrel/Core/src/HttpsConnectionAdapterOptions.cs +++ b/src/Servers/Kestrel/Core/src/HttpsConnectionAdapterOptions.cs @@ -16,6 +16,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Https /// public class HttpsConnectionAdapterOptions { + internal static TimeSpan DefaultHandshakeTimeout = TimeSpan.FromSeconds(10); + private TimeSpan _handshakeTimeout; /// @@ -24,7 +26,7 @@ public class HttpsConnectionAdapterOptions public HttpsConnectionAdapterOptions() { ClientCertificateMode = ClientCertificateMode.NoCertificate; - HandshakeTimeout = TimeSpan.FromSeconds(10); + HandshakeTimeout = DefaultHandshakeTimeout; } /// @@ -91,7 +93,7 @@ public void AllowAnyClientCertificate() public Action OnAuthenticate { get; set; } /// - /// Specifies the maximum amount of time allowed for the TLS/SSL handshake. This must be positive and finite. + /// Specifies the maximum amount of time allowed for the TLS/SSL handshake. This must be positive and finite. Defaults to 10 seconds. /// public TimeSpan HandshakeTimeout { diff --git a/src/Servers/Kestrel/Core/src/Internal/Certificates/CertificateConfigLoader.cs b/src/Servers/Kestrel/Core/src/Internal/Certificates/CertificateConfigLoader.cs new file mode 100644 index 000000000000..f2663b9fbcd1 --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/Certificates/CertificateConfigLoader.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Certificates +{ + internal class CertificateConfigLoader : ICertificateConfigLoader + { + public CertificateConfigLoader(IHostEnvironment hostEnvironment, ILogger logger) + { + HostEnvironment = hostEnvironment; + Logger = logger; + } + + public IHostEnvironment HostEnvironment { get; } + public ILogger Logger { get; } + + public bool IsTestMock => false; + + public X509Certificate2 LoadCertificate(CertificateConfig certInfo, string endpointName) + { + if (certInfo is null) + { + return null; + } + + if (certInfo.IsFileCert && certInfo.IsStoreCert) + { + throw new InvalidOperationException(CoreStrings.FormatMultipleCertificateSources(endpointName)); + } + else if (certInfo.IsFileCert) + { + var certificatePath = Path.Combine(HostEnvironment.ContentRootPath, certInfo.Path); + if (certInfo.KeyPath != null) + { + var certificateKeyPath = Path.Combine(HostEnvironment.ContentRootPath, certInfo.KeyPath); + var certificate = GetCertificate(certificatePath); + + if (certificate != null) + { + certificate = LoadCertificateKey(certificate, certificateKeyPath, certInfo.Password); + } + else + { + Logger.FailedToLoadCertificate(certificateKeyPath); + } + + if (certificate != null) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return PersistKey(certificate); + } + + return certificate; + } + else + { + Logger.FailedToLoadCertificateKey(certificateKeyPath); + } + + throw new InvalidOperationException(CoreStrings.InvalidPemKey); + } + + return new X509Certificate2(Path.Combine(HostEnvironment.ContentRootPath, certInfo.Path), certInfo.Password); + } + else if (certInfo.IsStoreCert) + { + return LoadFromStoreCert(certInfo); + } + + return null; + } + + private static X509Certificate2 PersistKey(X509Certificate2 fullCertificate) + { + // We need to force the key to be persisted. + // See https://github.com/dotnet/runtime/issues/23749 + var certificateBytes = fullCertificate.Export(X509ContentType.Pkcs12, ""); + return new X509Certificate2(certificateBytes, "", X509KeyStorageFlags.DefaultKeySet); + } + + private static X509Certificate2 LoadCertificateKey(X509Certificate2 certificate, string keyPath, string password) + { + // OIDs for the certificate key types. + const string RSAOid = "1.2.840.113549.1.1.1"; + const string DSAOid = "1.2.840.10040.4.1"; + const string ECDsaOid = "1.2.840.10045.2.1"; + + var keyText = File.ReadAllText(keyPath); + return certificate.PublicKey.Oid.Value switch + { + RSAOid => AttachPemRSAKey(certificate, keyText, password), + ECDsaOid => AttachPemECDSAKey(certificate, keyText, password), + DSAOid => AttachPemDSAKey(certificate, keyText, password), + _ => throw new InvalidOperationException(string.Format(CoreStrings.UnrecognizedCertificateKeyOid, certificate.PublicKey.Oid.Value)) + }; + } + + private static X509Certificate2 GetCertificate(string certificatePath) + { + if (X509Certificate2.GetCertContentType(certificatePath) == X509ContentType.Cert) + { + return new X509Certificate2(certificatePath); + } + + return null; + } + + private static X509Certificate2 AttachPemRSAKey(X509Certificate2 certificate, string keyText, string password) + { + using var rsa = RSA.Create(); + if (password == null) + { + rsa.ImportFromPem(keyText); + } + else + { + rsa.ImportFromEncryptedPem(keyText, password); + } + + return certificate.CopyWithPrivateKey(rsa); + } + + private static X509Certificate2 AttachPemDSAKey(X509Certificate2 certificate, string keyText, string password) + { + using var dsa = DSA.Create(); + if (password == null) + { + dsa.ImportFromPem(keyText); + } + else + { + dsa.ImportFromEncryptedPem(keyText, password); + } + + return certificate.CopyWithPrivateKey(dsa); + } + + private static X509Certificate2 AttachPemECDSAKey(X509Certificate2 certificate, string keyText, string password) + { + using var ecdsa = ECDsa.Create(); + if (password == null) + { + ecdsa.ImportFromPem(keyText); + } + else + { + ecdsa.ImportFromEncryptedPem(keyText, password); + } + + return certificate.CopyWithPrivateKey(ecdsa); + } + + private static X509Certificate2 LoadFromStoreCert(CertificateConfig certInfo) + { + var subject = certInfo.Subject; + var storeName = string.IsNullOrEmpty(certInfo.Store) ? StoreName.My.ToString() : certInfo.Store; + var location = certInfo.Location; + var storeLocation = StoreLocation.CurrentUser; + if (!string.IsNullOrEmpty(location)) + { + storeLocation = (StoreLocation)Enum.Parse(typeof(StoreLocation), location, ignoreCase: true); + } + var allowInvalid = certInfo.AllowInvalid ?? false; + + return CertificateLoader.LoadFromStoreCert(subject, storeName, storeLocation, allowInvalid); + } + } +} diff --git a/src/Servers/Kestrel/Core/src/Internal/Certificates/ICertificateConfigLoader.cs b/src/Servers/Kestrel/Core/src/Internal/Certificates/ICertificateConfigLoader.cs new file mode 100644 index 000000000000..53cf84f42b9c --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/Certificates/ICertificateConfigLoader.cs @@ -0,0 +1,14 @@ +// 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.AspNetCore.Server.Kestrel.Core.Internal.Certificates +{ + internal interface ICertificateConfigLoader + { + bool IsTestMock { get; } + + X509Certificate2 LoadCertificate(CertificateConfig certInfo, string endpointName); + } +} diff --git a/src/Servers/Kestrel/Core/src/Internal/ConfigurationReader.cs b/src/Servers/Kestrel/Core/src/Internal/ConfigurationReader.cs index 894cd0fa01ed..06ca82a8a947 100644 --- a/src/Servers/Kestrel/Core/src/Internal/ConfigurationReader.cs +++ b/src/Servers/Kestrel/Core/src/Internal/ConfigurationReader.cs @@ -20,6 +20,7 @@ internal class ConfigurationReader private const string EndpointsKey = "Endpoints"; private const string UrlKey = "Url"; private const string ClientCertificateModeKey = "ClientCertificateMode"; + private const string SniKey = "Sni"; private readonly IConfiguration _configuration; @@ -50,9 +51,9 @@ private IDictionary ReadCertificates() } // "EndpointDefaults": { - // "Protocols": "Http1AndHttp2", - // "SslProtocols": [ "Tls11", "Tls12", "Tls13"], - // "ClientCertificateMode" : "NoCertificate" + // "Protocols": "Http1AndHttp2", + // "SslProtocols": [ "Tls11", "Tls12", "Tls13"], + // "ClientCertificateMode" : "NoCertificate" // } private EndpointDefaults ReadEndpointDefaults() { @@ -61,7 +62,7 @@ private EndpointDefaults ReadEndpointDefaults() { Protocols = ParseProtocols(configSection[ProtocolsKey]), SslProtocols = ParseSslProcotols(configSection.GetSection(SslProtocolsKey)), - ClientCertificateMode = ParseClientCertificateMode(configSection[ClientCertificateModeKey]) + ClientCertificateMode = ParseClientCertificateMode(configSection[ClientCertificateModeKey]), }; } @@ -73,14 +74,25 @@ private IEnumerable ReadEndpoints() foreach (var endpointConfig in endpointsConfig) { // "EndpointName": { - // "Url": "https://*:5463", - // "Protocols": "Http1AndHttp2", - // "SslProtocols": [ "Tls11", "Tls12", "Tls13"], - // "Certificate": { - // "Path": "testCert.pfx", - // "Password": "testPassword" - // }, - // "ClientCertificateMode" : "NoCertificate" + // "Url": "https://*:5463", + // "Protocols": "Http1AndHttp2", + // "SslProtocols": [ "Tls11", "Tls12", "Tls13"], + // "Certificate": { + // "Path": "testCert.pfx", + // "Password": "testPassword" + // }, + // "ClientCertificateMode" : "NoCertificate", + // "Sni": { + // "a.example.org": { + // "Certificate": { + // "Path": "testCertA.pfx", + // "Password": "testPassword" + // } + // }, + // "*.example.org": { + // "Protocols": "Http1", + // } + // } // } var url = endpointConfig[UrlKey]; @@ -97,7 +109,8 @@ private IEnumerable ReadEndpoints() ConfigSection = endpointConfig, Certificate = new CertificateConfig(endpointConfig.GetSection(CertificateKey)), SslProtocols = ParseSslProcotols(endpointConfig.GetSection(SslProtocolsKey)), - ClientCertificateMode = ParseClientCertificateMode(endpointConfig[ClientCertificateModeKey]) + ClientCertificateMode = ParseClientCertificateMode(endpointConfig[ClientCertificateModeKey]), + Sni = ReadSni(endpointConfig.GetSection(SniKey), endpointConfig.Key), }; endpoints.Add(endpoint); @@ -106,7 +119,53 @@ private IEnumerable ReadEndpoints() return endpoints; } - private ClientCertificateMode? ParseClientCertificateMode(string clientCertificateMode) + private static Dictionary ReadSni(IConfigurationSection sniConfig, string endpointName) + { + var sniDictionary = new Dictionary(0, StringComparer.OrdinalIgnoreCase); + + foreach (var sniChild in sniConfig.GetChildren()) + { + // "Sni": { + // "a.example.org": { + // "Protocols": "Http1", + // "SslProtocols": [ "Tls11", "Tls12", "Tls13"], + // "Certificate": { + // "Path": "testCertA.pfx", + // "Password": "testPassword" + // }, + // "ClientCertificateMode" : "NoCertificate" + // }, + // "*.example.org": { + // "Certificate": { + // "Path": "testCertWildcard.pfx", + // "Password": "testPassword" + // } + // } + // // The following should work once https://github.com/dotnet/runtime/issues/40218 is resolved + // "*": {} + // } + + if (string.IsNullOrEmpty(sniChild.Key)) + { + throw new InvalidOperationException(CoreStrings.FormatSniNameCannotBeEmpty(endpointName)); + } + + var sni = new SniConfig + { + Certificate = new CertificateConfig(sniChild.GetSection(CertificateKey)), + Protocols = ParseProtocols(sniChild[ProtocolsKey]), + SslProtocols = ParseSslProcotols(sniChild.GetSection(SslProtocolsKey)), + ClientCertificateMode = ParseClientCertificateMode(sniChild[ClientCertificateModeKey]) + }; + + sniDictionary.Add(sniChild.Key, sni); + } + + return sniDictionary; + } + + + private static ClientCertificateMode? ParseClientCertificateMode(string clientCertificateMode) { if (Enum.TryParse(clientCertificateMode, ignoreCase: true, out var result)) { @@ -140,12 +199,35 @@ private IEnumerable ReadEndpoints() return acc; }); } + + internal static void ThrowIfContainsHttpsOnlyConfiguration(EndpointConfig endpoint) + { + if (endpoint.Certificate.IsFileCert || endpoint.Certificate.IsStoreCert) + { + throw new InvalidOperationException(CoreStrings.FormatEndpointHasUnusedHttpsConfig(endpoint.Name, CertificateKey)); + } + + if (endpoint.ClientCertificateMode.HasValue) + { + throw new InvalidOperationException(CoreStrings.FormatEndpointHasUnusedHttpsConfig(endpoint.Name, ClientCertificateModeKey)); + } + + if (endpoint.SslProtocols.HasValue) + { + throw new InvalidOperationException(CoreStrings.FormatEndpointHasUnusedHttpsConfig(endpoint.Name, SslProtocolsKey)); + } + + if (endpoint.Sni.Count > 0) + { + throw new InvalidOperationException(CoreStrings.FormatEndpointHasUnusedHttpsConfig(endpoint.Name, SniKey)); + } + } } // "EndpointDefaults": { - // "Protocols": "Http1AndHttp2", - // "SslProtocols": [ "Tls11", "Tls12", "Tls13"], - // "ClientCertificateMode" : "NoCertificate" + // "Protocols": "Http1AndHttp2", + // "SslProtocols": [ "Tls11", "Tls12", "Tls13"], + // "ClientCertificateMode" : "NoCertificate" // } internal class EndpointDefaults { @@ -155,14 +237,25 @@ internal class EndpointDefaults } // "EndpointName": { - // "Url": "https://*:5463", - // "Protocols": "Http1AndHttp2", - // "SslProtocols": [ "Tls11", "Tls12", "Tls13"], - // "Certificate": { - // "Path": "testCert.pfx", - // "Password": "testPassword" - // }, - // "ClientCertificateMode" : "NoCertificate" + // "Url": "https://*:5463", + // "Protocols": "Http1AndHttp2", + // "SslProtocols": [ "Tls11", "Tls12", "Tls13"], + // "Certificate": { + // "Path": "testCert.pfx", + // "Password": "testPassword" + // }, + // "ClientCertificateMode" : "NoCertificate", + // "Sni": { + // "a.example.org": { + // "Certificate": { + // "Path": "testCertA.pfx", + // "Password": "testPasswordA" + // } + // }, + // "*.example.org": { + // "Protocols": "Http1", + // } + // } // } internal class EndpointConfig { @@ -175,6 +268,7 @@ internal class EndpointConfig public SslProtocols? SslProtocols { get; set; } public CertificateConfig Certificate { get; set; } public ClientCertificateMode? ClientCertificateMode { get; set; } + public Dictionary Sni { get; set; } // Compare config sections because it's accessible to app developers via an Action callback. // We cannot rely entirely on comparing config sections for equality, because KestrelConfigurationLoader.Reload() sets @@ -196,19 +290,63 @@ obj is EndpointConfig other && Name == other.Name && Url == other.Url && (Protocols ?? ListenOptions.DefaultHttpProtocols) == (other.Protocols ?? ListenOptions.DefaultHttpProtocols) && - Certificate == other.Certificate && (SslProtocols ?? System.Security.Authentication.SslProtocols.None) == (other.SslProtocols ?? System.Security.Authentication.SslProtocols.None) && + Certificate == other.Certificate && + (ClientCertificateMode ?? Https.ClientCertificateMode.NoCertificate) == (other.ClientCertificateMode ?? Https.ClientCertificateMode.NoCertificate) && + CompareSniDictionaries(Sni, other.Sni) && _configSectionClone == other._configSectionClone; - public override int GetHashCode() => HashCode.Combine(Name, Url, Protocols ?? ListenOptions.DefaultHttpProtocols, Certificate, _configSectionClone); + public override int GetHashCode() => HashCode.Combine(Name, Url, + Protocols ?? ListenOptions.DefaultHttpProtocols, SslProtocols ?? System.Security.Authentication.SslProtocols.None, + Certificate, ClientCertificateMode ?? Https.ClientCertificateMode.NoCertificate, Sni.Count, _configSectionClone); public static bool operator ==(EndpointConfig lhs, EndpointConfig rhs) => lhs is null ? rhs is null : lhs.Equals(rhs); public static bool operator !=(EndpointConfig lhs, EndpointConfig rhs) => !(lhs == rhs); + + private static bool CompareSniDictionaries(Dictionary lhs, Dictionary rhs) + { + if (lhs.Count != rhs.Count) + { + return false; + } + + foreach (var (lhsName, lhsSniConfig) in lhs) + { + if (!rhs.TryGetValue(lhsName, out var rhsSniConfig) || lhsSniConfig != rhsSniConfig) + { + return false; + } + } + + return true; + } + } + + internal class SniConfig + { + public HttpProtocols? Protocols { get; set; } + public SslProtocols? SslProtocols { get; set; } + public CertificateConfig Certificate { get; set; } + public ClientCertificateMode? ClientCertificateMode { get; set; } + + public override bool Equals(object obj) => + obj is SniConfig other && + (Protocols ?? ListenOptions.DefaultHttpProtocols) == (other.Protocols ?? ListenOptions.DefaultHttpProtocols) && + (SslProtocols ?? System.Security.Authentication.SslProtocols.None) == (other.SslProtocols ?? System.Security.Authentication.SslProtocols.None) && + Certificate == other.Certificate && + (ClientCertificateMode ?? Https.ClientCertificateMode.NoCertificate) == (other.ClientCertificateMode ?? Https.ClientCertificateMode.NoCertificate); + + public override int GetHashCode() => HashCode.Combine( + Protocols ?? ListenOptions.DefaultHttpProtocols, SslProtocols ?? System.Security.Authentication.SslProtocols.None, + Certificate, ClientCertificateMode ?? Https.ClientCertificateMode.NoCertificate); + + public static bool operator ==(SniConfig lhs, SniConfig rhs) => lhs is null ? rhs is null : lhs.Equals(rhs); + public static bool operator !=(SniConfig lhs, SniConfig rhs) => !(lhs == rhs); } // "CertificateName": { - // "Path": "testCert.pfx", - // "Password": "testPassword" + // "Path": "testCert.pfx", + // "Password": "testPassword" // } internal class CertificateConfig { @@ -218,6 +356,11 @@ public CertificateConfig(IConfigurationSection configSection) ConfigSection.Bind(this); } + // For testing + internal CertificateConfig() + { + } + public IConfigurationSection ConfigSection { get; } // File @@ -244,13 +387,14 @@ public CertificateConfig(IConfigurationSection configSection) public override bool Equals(object obj) => obj is CertificateConfig other && Path == other.Path && + KeyPath == other.KeyPath && Password == other.Password && Subject == other.Subject && Store == other.Store && Location == other.Location && (AllowInvalid ?? false) == (other.AllowInvalid ?? false); - public override int GetHashCode() => HashCode.Combine(Path, Password, Subject, Store, Location, AllowInvalid ?? false); + public override int GetHashCode() => HashCode.Combine(Path, KeyPath, Password, Subject, Store, Location, AllowInvalid ?? false); public static bool operator ==(CertificateConfig lhs, CertificateConfig rhs) => lhs is null ? rhs is null : lhs.Equals(rhs); public static bool operator !=(CertificateConfig lhs, CertificateConfig rhs) => !(lhs == rhs); diff --git a/src/Servers/Kestrel/Core/src/Internal/HttpProtocolsFeature.cs b/src/Servers/Kestrel/Core/src/Internal/HttpProtocolsFeature.cs new file mode 100644 index 000000000000..c29b2cc5013c --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/HttpProtocolsFeature.cs @@ -0,0 +1,15 @@ +// 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.AspNetCore.Server.Kestrel.Core.Internal +{ + internal class HttpProtocolsFeature + { + public HttpProtocolsFeature(HttpProtocols httpProtocols) + { + HttpProtocols = httpProtocols; + } + + public HttpProtocols HttpProtocols { get; } + } +} diff --git a/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs b/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs index a28c74ae872e..636dec7454f5 100644 --- a/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs +++ b/src/Servers/Kestrel/Core/src/Internal/LoggerExtensions.cs @@ -58,19 +58,20 @@ internal static class LoggerExtensions new EventId(7, "MissingOrInvalidCertificateKeyFile"), "The certificate key file at '{CertificateKeyFilePath}' can not be found, contains malformed data or does not contain a PEM encoded key in PKCS8 format."); - public static void LocatedDevelopmentCertificate(this ILogger logger, X509Certificate2 certificate) => _locatedDevelopmentCertificate(logger, certificate.Subject, certificate.Thumbprint, null); + public static void LocatedDevelopmentCertificate(this ILogger logger, X509Certificate2 certificate) => _locatedDevelopmentCertificate(logger, certificate.Subject, certificate.Thumbprint, null); - public static void UnableToLocateDevelopmentCertificate(this ILogger logger) => _unableToLocateDevelopmentCertificate(logger, null); + public static void UnableToLocateDevelopmentCertificate(this ILogger logger) => _unableToLocateDevelopmentCertificate(logger, null); - public static void FailedToLocateDevelopmentCertificateFile(this ILogger logger, string certificatePath) => _failedToLocateDevelopmentCertificateFile(logger, certificatePath, null); + public static void FailedToLocateDevelopmentCertificateFile(this ILogger logger, string certificatePath) => _failedToLocateDevelopmentCertificateFile(logger, certificatePath, null); - public static void FailedToLoadDevelopmentCertificate(this ILogger logger, string certificatePath) => _failedToLoadDevelopmentCertificate(logger, certificatePath, null); + public static void FailedToLoadDevelopmentCertificate(this ILogger logger, string certificatePath) => _failedToLoadDevelopmentCertificate(logger, certificatePath, null); - public static void BadDeveloperCertificateState(this ILogger logger) => _badDeveloperCertificateState(logger, null); + public static void BadDeveloperCertificateState(this ILogger logger) => _badDeveloperCertificateState(logger, null); - public static void DeveloperCertificateFirstRun(this ILogger logger, string message) => _developerCertificateFirstRun(logger, message, null); + public static void DeveloperCertificateFirstRun(this ILogger logger, string message) => _developerCertificateFirstRun(logger, message, null); - public static void FailedToLoadCertificate(this ILogger logger, string certificatePath) => _failedToLoadCertificate(logger, certificatePath, null); - public static void FailedToLoadCertificateKey(this ILogger logger, string certificateKeyPath) => _failedToLoadCertificateKey(logger, certificateKeyPath, null); + public static void FailedToLoadCertificate(this ILogger logger, string certificatePath) => _failedToLoadCertificate(logger, certificatePath, null); + + public static void FailedToLoadCertificateKey(this ILogger logger, string certificateKeyPath) => _failedToLoadCertificateKey(logger, certificateKeyPath, null); } } diff --git a/src/Servers/Kestrel/Core/src/Internal/SniOptionsSelector.cs b/src/Servers/Kestrel/Core/src/Internal/SniOptionsSelector.cs new file mode 100644 index 000000000000..279ca5c65c38 --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/SniOptionsSelector.cs @@ -0,0 +1,219 @@ +// 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; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Certificates; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal +{ + internal class SniOptionsSelector + { + private const string WildcardHost = "*"; + private const string WildcardPrefix = "*."; + + private readonly string _endpointName; + + private readonly Func _fallbackServerCertificateSelector; + private readonly Action _onAuthenticateCallback; + + private readonly Dictionary _exactNameOptions = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly SortedList _wildcardPrefixOptions = new SortedList(LongestStringFirstComparer.Instance); + private readonly SniOptions _wildcardOptions; + + public SniOptionsSelector( + string endpointName, + Dictionary sniDictionary, + ICertificateConfigLoader certifcateConfigLoader, + HttpsConnectionAdapterOptions fallbackHttpsOptions, + HttpProtocols fallbackHttpProtocols, + ILogger logger) + { + _endpointName = endpointName; + + _fallbackServerCertificateSelector = fallbackHttpsOptions.ServerCertificateSelector; + _onAuthenticateCallback = fallbackHttpsOptions.OnAuthenticate; + + foreach (var (name, sniConfig) in sniDictionary) + { + var sslOptions = new SslServerAuthenticationOptions + { + ServerCertificate = certifcateConfigLoader.LoadCertificate(sniConfig.Certificate, $"{endpointName}:Sni:{name}"), + EnabledSslProtocols = sniConfig.SslProtocols ?? fallbackHttpsOptions.SslProtocols, + CertificateRevocationCheckMode = fallbackHttpsOptions.CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, + }; + + if (sslOptions.ServerCertificate is null) + { + if (fallbackHttpsOptions.ServerCertificate is null && _fallbackServerCertificateSelector is null) + { + throw new InvalidOperationException(CoreStrings.NoCertSpecifiedNoDevelopmentCertificateFound); + } + + if (_fallbackServerCertificateSelector is null) + { + // Cache the fallback ServerCertificate since there's no fallback ServerCertificateSelector taking precedence. + sslOptions.ServerCertificate = fallbackHttpsOptions.ServerCertificate; + } + } + + if (!certifcateConfigLoader.IsTestMock && sslOptions.ServerCertificate is X509Certificate2 cert2) + { + HttpsConnectionMiddleware.EnsureCertificateIsAllowedForServerAuth(cert2); + } + + var clientCertificateMode = sniConfig.ClientCertificateMode ?? fallbackHttpsOptions.ClientCertificateMode; + + if (clientCertificateMode != ClientCertificateMode.NoCertificate) + { + sslOptions.ClientCertificateRequired = true; + sslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + HttpsConnectionMiddleware.RemoteCertificateValidationCallback( + clientCertificateMode, fallbackHttpsOptions.ClientCertificateValidation, certificate, chain, sslPolicyErrors); + } + + var httpProtocols = sniConfig.Protocols ?? fallbackHttpProtocols; + httpProtocols = HttpsConnectionMiddleware.ValidateAndNormalizeHttpProtocols(httpProtocols, logger); + HttpsConnectionMiddleware.ConfigureAlpn(sslOptions, httpProtocols); + + var sniOptions = new SniOptions + { + SslOptions = sslOptions, + HttpProtocols = httpProtocols, + }; + + if (name.Equals(WildcardHost, StringComparison.Ordinal)) + { + _wildcardOptions = sniOptions; + } + else if (name.StartsWith(WildcardPrefix, StringComparison.Ordinal)) + { + // Only slice off 1 character, the `*`. We want to match the leading `.` also. + _wildcardPrefixOptions.Add(name.Substring(1), sniOptions); + } + else + { + _exactNameOptions.Add(name, sniOptions); + } + } + } + + public SslServerAuthenticationOptions GetOptions(ConnectionContext connection, string serverName) + { + SniOptions sniOptions = null; + + if (!string.IsNullOrEmpty(serverName) && !_exactNameOptions.TryGetValue(serverName, out sniOptions)) + { + foreach (var (suffix, options) in _wildcardPrefixOptions) + { + if (serverName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + sniOptions = options; + break; + } + } + } + + // Fully wildcarded ("*") options can be used even when given an empty server name. + sniOptions ??= _wildcardOptions; + + if (sniOptions is null) + { + if (serverName is null) + { + // There was no ALPN + throw new AuthenticationException(CoreStrings.FormatSniNotConfiguredToAllowNoServerName(_endpointName)); + } + else + { + throw new AuthenticationException(CoreStrings.FormatSniNotConfiguredForServerName(serverName, _endpointName)); + } + } + + connection.Features.Set(new HttpProtocolsFeature(sniOptions.HttpProtocols)); + + var sslOptions = sniOptions.SslOptions; + + if (sslOptions.ServerCertificate is null) + { + Debug.Assert(_fallbackServerCertificateSelector != null, + "The cached SniOptions ServerCertificate can only be null if there's a fallback certificate selector."); + + // If a ServerCertificateSelector doesn't return a cert, HttpsConnectionMiddleware doesn't fallback to the ServerCertificate. + sslOptions = CloneSslOptions(sslOptions); + var fallbackCertificate = _fallbackServerCertificateSelector(connection, serverName); + + if (fallbackCertificate != null) + { + HttpsConnectionMiddleware.EnsureCertificateIsAllowedForServerAuth(fallbackCertificate); + } + + sslOptions.ServerCertificate = fallbackCertificate; + } + + if (_onAuthenticateCallback != null) + { + // From doc comments: "This is called after all of the other settings have already been applied." + sslOptions = CloneSslOptions(sslOptions); + _onAuthenticateCallback(connection, sslOptions); + } + + return sslOptions; + } + + public static ValueTask OptionsCallback(ConnectionContext connection, SslStream stream, SslClientHelloInfo clientHelloInfo, object state, CancellationToken cancellationToken) + { + var sniOptionsSelector = (SniOptionsSelector)state; + var options = sniOptionsSelector.GetOptions(connection, clientHelloInfo.ServerName); + return new ValueTask(options); + } + + internal static SslServerAuthenticationOptions CloneSslOptions(SslServerAuthenticationOptions sslOptions) => + new SslServerAuthenticationOptions + { + AllowRenegotiation = sslOptions.AllowRenegotiation, + ApplicationProtocols = sslOptions.ApplicationProtocols?.ToList(), + CertificateRevocationCheckMode = sslOptions.CertificateRevocationCheckMode, + CipherSuitesPolicy = sslOptions.CipherSuitesPolicy, + ClientCertificateRequired = sslOptions.ClientCertificateRequired, + EnabledSslProtocols = sslOptions.EnabledSslProtocols, + EncryptionPolicy = sslOptions.EncryptionPolicy, + RemoteCertificateValidationCallback = sslOptions.RemoteCertificateValidationCallback, + ServerCertificate = sslOptions.ServerCertificate, + ServerCertificateContext = sslOptions.ServerCertificateContext, + ServerCertificateSelectionCallback = sslOptions.ServerCertificateSelectionCallback, + }; + + private class SniOptions + { + public SslServerAuthenticationOptions SslOptions { get; set; } + public HttpProtocols HttpProtocols { get; set; } + } + + private class LongestStringFirstComparer : IComparer + { + public static LongestStringFirstComparer Instance { get; } = new LongestStringFirstComparer(); + + private LongestStringFirstComparer() + { + } + + public int Compare(string x, string y) + { + // Flip x and y to put the longest instead of the shortest string first in the SortedList. + return y.Length.CompareTo(x.Length); + } + } + } +} diff --git a/src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs b/src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs index 86e63ddc6a73..cc353b292f9e 100644 --- a/src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs +++ b/src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs @@ -6,17 +6,16 @@ using System.IO; using System.Linq; using System.Net; -using System.Runtime.InteropServices; -using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Certificates.Generation; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Certificates; using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -26,13 +25,24 @@ public class KestrelConfigurationLoader { private bool _loaded = false; - internal KestrelConfigurationLoader(KestrelServerOptions options, IConfiguration configuration, bool reloadOnChange) + internal KestrelConfigurationLoader( + KestrelServerOptions options, + IConfiguration configuration, + IHostEnvironment hostEnvironment, + bool reloadOnChange, + ILogger logger, + ILogger httpsLogger) { Options = options ?? throw new ArgumentNullException(nameof(options)); Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + HostEnvironment = hostEnvironment ?? throw new ArgumentNullException(nameof(hostEnvironment)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + HttpsLogger = httpsLogger ?? throw new ArgumentNullException(nameof(logger)); + ReloadOnChange = reloadOnChange; ConfigurationReader = new ConfigurationReader(configuration); + CertificateConfigLoader = new CertificateConfigLoader(hostEnvironment, logger); } public KestrelServerOptions Options { get; } @@ -44,8 +54,14 @@ internal KestrelConfigurationLoader(KestrelServerOptions options, IConfiguration /// internal bool ReloadOnChange { get; } + private IHostEnvironment HostEnvironment { get; } + private ILogger Logger { get; } + private ILogger HttpsLogger { get; } + private ConfigurationReader ConfigurationReader { get; set; } + private ICertificateConfigLoader CertificateConfigLoader { get; } + private IDictionary> EndpointConfigurations { get; } = new Dictionary>(0, StringComparer.OrdinalIgnoreCase); @@ -215,9 +231,9 @@ public KestrelConfigurationLoader HandleEndpoint(ulong handle, Action { }) var httpsOptions = new HttpsConnectionAdapterOptions(); + if (https) { - httpsOptions.SslProtocols = ConfigurationReader.EndpointDefaults.SslProtocols ?? SslProtocols.None; - httpsOptions.ClientCertificateMode = ConfigurationReader.EndpointDefaults.ClientCertificateMode ?? ClientCertificateMode.NoCertificate; - // Defaults Options.ApplyHttpsDefaults(httpsOptions); @@ -289,14 +325,24 @@ public void Load() { httpsOptions.SslProtocols = endpoint.SslProtocols.Value; } + else + { + // Ensure endpoint is reloaded if it used the default protocol and the SslProtocols changed. + endpoint.SslProtocols = ConfigurationReader.EndpointDefaults.SslProtocols; + } if (endpoint.ClientCertificateMode.HasValue) { httpsOptions.ClientCertificateMode = endpoint.ClientCertificateMode.Value; } + else + { + // Ensure endpoint is reloaded if it used the default mode and the ClientCertificateMode changed. + endpoint.ClientCertificateMode = ConfigurationReader.EndpointDefaults.ClientCertificateMode; + } - // Specified - httpsOptions.ServerCertificate = LoadCertificate(endpoint.Certificate, endpoint.Name) + // A cert specified directly on the endpoint overrides any defaults. + httpsOptions.ServerCertificate = CertificateConfigLoader.LoadCertificate(endpoint.Certificate, endpoint.Name) ?? httpsOptions.ServerCertificate; if (httpsOptions.ServerCertificate == null && httpsOptions.ServerCertificateSelector == null) @@ -329,12 +375,20 @@ public void Load() // EndpointDefaults or configureEndpoint may have added an https adapter. if (https && !listenOptions.IsTls) { - if (httpsOptions.ServerCertificate == null && httpsOptions.ServerCertificateSelector == null) + if (endpoint.Sni.Count == 0) { - throw new InvalidOperationException(CoreStrings.NoCertSpecifiedNoDevelopmentCertificateFound); - } + if (httpsOptions.ServerCertificate == null && httpsOptions.ServerCertificateSelector == null) + { + throw new InvalidOperationException(CoreStrings.NoCertSpecifiedNoDevelopmentCertificateFound); + } - listenOptions.UseHttps(httpsOptions); + listenOptions.UseHttps(httpsOptions); + } + else + { + var sniOptionsSelector = new SniOptionsSelector(endpoint.Name, endpoint.Sni, CertificateConfigLoader, httpsOptions, listenOptions.Protocols, HttpsLogger); + listenOptions.UseHttps(SniOptionsSelector.OptionsCallback, sniOptionsSelector, httpsOptions.HandshakeTimeout); + } } listenOptions.EndpointConfig = endpoint; @@ -346,11 +400,11 @@ public void Load() return (endpointsToStop, endpointsToStart); } - private void LoadDefaultCert(ConfigurationReader configReader) + private void LoadDefaultCert() { - if (configReader.Certificates.TryGetValue("Default", out var defaultCertConfig)) + if (ConfigurationReader.Certificates.TryGetValue("Default", out var defaultCertConfig)) { - var defaultCert = LoadCertificate(defaultCertConfig, "Default"); + var defaultCert = CertificateConfigLoader.LoadCertificate(defaultCertConfig, "Default"); if (defaultCert != null) { DefaultCertificateConfig = defaultCertConfig; @@ -359,23 +413,22 @@ private void LoadDefaultCert(ConfigurationReader configReader) } else { - var logger = Options.ApplicationServices.GetRequiredService>(); - var (certificate, certificateConfig) = FindDeveloperCertificateFile(configReader, logger); + var (certificate, certificateConfig) = FindDeveloperCertificateFile(); if (certificate != null) { - logger.LocatedDevelopmentCertificate(certificate); + Logger.LocatedDevelopmentCertificate(certificate); DefaultCertificateConfig = certificateConfig; Options.DefaultCertificate = certificate; } } } - private (X509Certificate2, CertificateConfig) FindDeveloperCertificateFile(ConfigurationReader configReader, ILogger logger) + private (X509Certificate2, CertificateConfig) FindDeveloperCertificateFile() { string certificatePath = null; try { - if (configReader.Certificates.TryGetValue("Development", out var certificateConfig) && + if (ConfigurationReader.Certificates.TryGetValue("Development", out var certificateConfig) && certificateConfig.Path == null && certificateConfig.Password != null && TryGetCertificatePath(out certificatePath) && @@ -390,12 +443,12 @@ private void LoadDefaultCert(ConfigurationReader configReader) } else if (!string.IsNullOrEmpty(certificatePath)) { - logger.FailedToLocateDevelopmentCertificateFile(certificatePath); + Logger.FailedToLocateDevelopmentCertificateFile(certificatePath); } } catch (CryptographicException) { - logger.FailedToLoadDevelopmentCertificate(certificatePath); + Logger.FailedToLoadDevelopmentCertificate(certificatePath); } return (null, null); @@ -421,163 +474,14 @@ private static bool IsDevelopmentCertificate(X509Certificate2 certificate) private bool TryGetCertificatePath(out string path) { - var hostingEnvironment = Options.ApplicationServices.GetRequiredService(); - var appName = hostingEnvironment.ApplicationName; - // This will go away when we implement // https://github.com/aspnet/Hosting/issues/1294 var appData = Environment.GetEnvironmentVariable("APPDATA"); var home = Environment.GetEnvironmentVariable("HOME"); var basePath = appData != null ? Path.Combine(appData, "ASP.NET", "https") : null; basePath = basePath ?? (home != null ? Path.Combine(home, ".aspnet", "https") : null); - path = basePath != null ? Path.Combine(basePath, $"{appName}.pfx") : null; + path = basePath != null ? Path.Combine(basePath, $"{HostEnvironment.ApplicationName}.pfx") : null; return path != null; } - - private X509Certificate2 LoadCertificate(CertificateConfig certInfo, string endpointName) - { - var logger = Options.ApplicationServices.GetRequiredService>(); - if (certInfo.IsFileCert && certInfo.IsStoreCert) - { - throw new InvalidOperationException(CoreStrings.FormatMultipleCertificateSources(endpointName)); - } - else if (certInfo.IsFileCert) - { - var environment = Options.ApplicationServices.GetRequiredService(); - var certificatePath = Path.Combine(environment.ContentRootPath, certInfo.Path); - if (certInfo.KeyPath != null) - { - var certificateKeyPath = Path.Combine(environment.ContentRootPath, certInfo.KeyPath); - var certificate = GetCertificate(certificatePath); - - if (certificate != null) - { - certificate = LoadCertificateKey(certificate, certificateKeyPath, certInfo.Password); - } - else - { - logger.FailedToLoadCertificate(certificateKeyPath); - } - - if (certificate != null) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return PersistKey(certificate); - } - - return certificate; - } - else - { - logger.FailedToLoadCertificateKey(certificateKeyPath); - } - - throw new InvalidOperationException(CoreStrings.InvalidPemKey); - } - - return new X509Certificate2(Path.Combine(environment.ContentRootPath, certInfo.Path), certInfo.Password); - } - else if (certInfo.IsStoreCert) - { - return LoadFromStoreCert(certInfo); - } - return null; - - static X509Certificate2 PersistKey(X509Certificate2 fullCertificate) - { - // We need to force the key to be persisted. - // See https://github.com/dotnet/runtime/issues/23749 - var certificateBytes = fullCertificate.Export(X509ContentType.Pkcs12, ""); - return new X509Certificate2(certificateBytes, "", X509KeyStorageFlags.DefaultKeySet); - } - - static X509Certificate2 LoadCertificateKey(X509Certificate2 certificate, string keyPath, string password) - { - // OIDs for the certificate key types. - const string RSAOid = "1.2.840.113549.1.1.1"; - const string DSAOid = "1.2.840.10040.4.1"; - const string ECDsaOid = "1.2.840.10045.2.1"; - - var keyText = File.ReadAllText(keyPath); - return certificate.PublicKey.Oid.Value switch - { - RSAOid => AttachPemRSAKey(certificate, keyText, password), - ECDsaOid => AttachPemECDSAKey(certificate, keyText, password), - DSAOid => AttachPemDSAKey(certificate, keyText, password), - _ => throw new InvalidOperationException(string.Format(CoreStrings.UnrecognizedCertificateKeyOid, certificate.PublicKey.Oid.Value)) - }; - } - - static X509Certificate2 GetCertificate(string certificatePath) - { - if (X509Certificate2.GetCertContentType(certificatePath) == X509ContentType.Cert) - { - return new X509Certificate2(certificatePath); - } - - return null; - } - } - - private static X509Certificate2 AttachPemRSAKey(X509Certificate2 certificate, string keyText, string password) - { - using var rsa = RSA.Create(); - if (password == null) - { - rsa.ImportFromPem(keyText); - } - else - { - rsa.ImportFromEncryptedPem(keyText, password); - } - - return certificate.CopyWithPrivateKey(rsa); - } - - private static X509Certificate2 AttachPemDSAKey(X509Certificate2 certificate, string keyText, string password) - { - using var dsa = DSA.Create(); - if (password == null) - { - dsa.ImportFromPem(keyText); - } - else - { - dsa.ImportFromEncryptedPem(keyText, password); - } - - return certificate.CopyWithPrivateKey(dsa); - } - - private static X509Certificate2 AttachPemECDSAKey(X509Certificate2 certificate, string keyText, string password) - { - using var ecdsa = ECDsa.Create(); - if (password == null) - { - ecdsa.ImportFromPem(keyText); - } - else - { - ecdsa.ImportFromEncryptedPem(keyText, password); - } - - return certificate.CopyWithPrivateKey(ecdsa); - } - - private static X509Certificate2 LoadFromStoreCert(CertificateConfig certInfo) - { - var subject = certInfo.Subject; - var storeName = string.IsNullOrEmpty(certInfo.Store) ? StoreName.My.ToString() : certInfo.Store; - var location = certInfo.Location; - var storeLocation = StoreLocation.CurrentUser; - if (!string.IsNullOrEmpty(location)) - { - storeLocation = (StoreLocation)Enum.Parse(typeof(StoreLocation), location, ignoreCase: true); - } - var allowInvalid = certInfo.AllowInvalid ?? false; - - return CertificateLoader.LoadFromStoreCert(subject, storeName, storeLocation, allowInvalid); - } } } diff --git a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs index 917c1c8a75f8..3fa65dd14653 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs @@ -12,9 +12,12 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Server.Kestrel.Core { @@ -138,7 +141,7 @@ public void ConfigureEndpointDefaults(Action configureOptions) internal void ApplyEndpointDefaults(ListenOptions listenOptions) { listenOptions.KestrelServerOptions = this; - ConfigurationLoader?.ApplyConfigurationDefaults(listenOptions); + ConfigurationLoader?.ApplyEndpointDefaults(listenOptions); EndpointDefaults(listenOptions); } @@ -153,6 +156,7 @@ public void ConfigureHttpsDefaults(Action configu internal void ApplyHttpsDefaults(HttpsConnectionAdapterOptions httpsOptions) { + ConfigurationLoader?.ApplyHttpsDefaults(httpsOptions); HttpsDefaults(httpsOptions); } @@ -240,7 +244,16 @@ private void EnsureDefaultCert() /// A for further endpoint configuration. public KestrelConfigurationLoader Configure(IConfiguration config, bool reloadOnChange) { - var loader = new KestrelConfigurationLoader(this, config, reloadOnChange); + if (ApplicationServices is null) + { + throw new InvalidOperationException($"{nameof(ApplicationServices)} must not be null. This is normally set automatically via {nameof(IConfigureOptions)}."); + } + + var hostEnvironment = ApplicationServices.GetRequiredService(); + var logger = ApplicationServices.GetRequiredService>(); + var httpsLogger = ApplicationServices.GetRequiredService>(); + + var loader = new KestrelConfigurationLoader(this, config, hostEnvironment, reloadOnChange, logger, httpsLogger); ConfigurationLoader = loader; return loader; } diff --git a/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs b/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs index d664fa5ee0e9..a65c0937ac0c 100644 --- a/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs +++ b/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs @@ -3,8 +3,10 @@ using System; using System.IO; +using System.Net.Security; using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; using Microsoft.Extensions.DependencyInjection; @@ -36,7 +38,7 @@ public static class ListenOptionsHttpsExtensions /// The . public static ListenOptions UseHttps(this ListenOptions listenOptions, string fileName) { - var env = listenOptions.KestrelServerOptions.ApplicationServices.GetRequiredService(); + var env = listenOptions.ApplicationServices.GetRequiredService(); return listenOptions.UseHttps(new X509Certificate2(Path.Combine(env.ContentRootPath, fileName))); } @@ -50,7 +52,7 @@ public static ListenOptions UseHttps(this ListenOptions listenOptions, string fi /// The . public static ListenOptions UseHttps(this ListenOptions listenOptions, string fileName, string password) { - var env = listenOptions.KestrelServerOptions.ApplicationServices.GetRequiredService(); + var env = listenOptions.ApplicationServices.GetRequiredService(); return listenOptions.UseHttps(new X509Certificate2(Path.Combine(env.ContentRootPath, fileName), password)); } @@ -65,7 +67,7 @@ public static ListenOptions UseHttps(this ListenOptions listenOptions, string fi public static ListenOptions UseHttps(this ListenOptions listenOptions, string fileName, string password, Action configureOptions) { - var env = listenOptions.KestrelServerOptions.ApplicationServices.GetRequiredService(); + var env = listenOptions.ApplicationServices.GetRequiredService(); return listenOptions.UseHttps(new X509Certificate2(Path.Combine(env.ContentRootPath, fileName), password), configureOptions); } @@ -227,5 +229,27 @@ public static ListenOptions UseHttps(this ListenOptions listenOptions, HttpsConn return listenOptions; } + + /// + /// Configure Kestrel to use HTTPS. + /// + /// The to configure. + /// Callback to configure HTTPS options. + /// State for the . + /// Specifies the maximum amount of time allowed for the TLS/SSL handshake. This must be positive and finite. + /// The . + internal static ListenOptions UseHttps(this ListenOptions listenOptions, HttpsOptionsCallback httpsOptionsCallback, object state, TimeSpan handshakeTimeout) + { + var loggerFactory = listenOptions.KestrelServerOptions?.ApplicationServices.GetRequiredService() ?? NullLoggerFactory.Instance; + + listenOptions.IsTls = true; + listenOptions.Use(next => + { + var middleware = new HttpsConnectionMiddleware(next, httpsOptionsCallback, state, handshakeTimeout, loggerFactory); + return middleware.OnConnectionAsync; + }); + + return listenOptions; + } } } diff --git a/src/Servers/Kestrel/Core/src/Middleware/HttpConnectionMiddleware.cs b/src/Servers/Kestrel/Core/src/Middleware/HttpConnectionMiddleware.cs index d5263f5c3741..3743b3d2fa0d 100644 --- a/src/Servers/Kestrel/Core/src/Middleware/HttpConnectionMiddleware.cs +++ b/src/Servers/Kestrel/Core/src/Middleware/HttpConnectionMiddleware.cs @@ -13,13 +13,13 @@ internal class HttpConnectionMiddleware { private readonly ServiceContext _serviceContext; private readonly IHttpApplication _application; - private readonly HttpProtocols _protocols; + private readonly HttpProtocols _endpointDefaultProtocols; public HttpConnectionMiddleware(ServiceContext serviceContext, IHttpApplication application, HttpProtocols protocols) { _serviceContext = serviceContext; _application = application; - _protocols = protocols; + _endpointDefaultProtocols = protocols; } public Task OnConnectionAsync(ConnectionContext connectionContext) @@ -30,7 +30,7 @@ public Task OnConnectionAsync(ConnectionContext connectionContext) { ConnectionId = connectionContext.ConnectionId, ConnectionContext = connectionContext, - Protocols = _protocols, + Protocols = connectionContext.Features.Get()?.HttpProtocols ?? _endpointDefaultProtocols, ServiceContext = _serviceContext, ConnectionFeatures = connectionContext.Features, MemoryPool = memoryPoolFeature?.MemoryPool ?? System.Buffers.MemoryPool.Shared, diff --git a/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs b/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs index 3f908091fc50..eb8ab43591bf 100644 --- a/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs +++ b/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.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; +using System.Buffers; using System.Collections.Generic; using System.IO; using System.IO.Pipelines; @@ -23,15 +24,28 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Https.Internal { + internal delegate ValueTask HttpsOptionsCallback(ConnectionContext connection, SslStream stream, SslClientHelloInfo clientHelloInfo, object state, CancellationToken cancellationToken); + internal class HttpsConnectionMiddleware { private const string EnableWindows81Http2 = "Microsoft.AspNetCore.Server.Kestrel.EnableWindows81Http2"; + + private static readonly bool _isWindowsVersionIncompatibleWithHttp2 = IsWindowsVersionIncompatibleWithHttp2(); + private readonly ConnectionDelegate _next; + private readonly TimeSpan _handshakeTimeout; + private readonly ILogger _logger; + private readonly Func _sslStreamFactory; + + // The following fields are only set by HttpsConnectionAdapterOptions ctor. private readonly HttpsConnectionAdapterOptions _options; - private readonly ILogger _logger; private readonly X509Certificate2 _serverCertificate; private readonly Func _serverCertificateSelector; + // The following fields are only set by ServerOptionsSelectionCallback ctor. + private readonly HttpsOptionsCallback _httpsOptionsCallback; + private readonly object _httpsOptionsCallbackState; + public HttpsConnectionMiddleware(ConnectionDelegate next, HttpsConnectionAdapterOptions options) : this(next, options, loggerFactory: NullLoggerFactory.Instance) { @@ -44,35 +58,27 @@ public HttpsConnectionMiddleware(ConnectionDelegate next, HttpsConnectionAdapter throw new ArgumentNullException(nameof(options)); } - _options = options; - _logger = loggerFactory.CreateLogger(); - - // This configuration will always fail per-request, preemptively fail it here. See HttpConnection.SelectProtocol(). - if (options.HttpProtocols == HttpProtocols.Http2) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - throw new NotSupportedException(CoreStrings.Http2NoTlsOsx); - } - else if (IsWindowsVersionIncompatible()) - { - throw new NotSupportedException(CoreStrings.Http2NoTlsWin81); - } - } - else if (options.HttpProtocols == HttpProtocols.Http1AndHttp2 && IsWindowsVersionIncompatible()) + if (options.ServerCertificate == null && options.ServerCertificateSelector == null) { - _logger.Http2DefaultCiphersInsufficient(); - options.HttpProtocols = HttpProtocols.Http1; + throw new ArgumentException(CoreStrings.ServerCertificateRequired, nameof(options)); } _next = next; + _handshakeTimeout = options.HandshakeTimeout; + _logger = loggerFactory.CreateLogger(); + + // Something similar to the following could allow us to remove more duplicate logic, but we need https://github.com/dotnet/runtime/issues/40402 to be fixed first. + //var sniOptionsSelector = new SniOptionsSelector("", new Dictionary { { "*", new SniConfig() } }, new NoopCertificateConfigLoader(), options, options.HttpProtocols, _logger); + //_httpsOptionsCallback = SniOptionsSelector.OptionsCallback; + //_httpsOptionsCallbackState = sniOptionsSelector; + //_sslStreamFactory = s => new SslStream(s); + + _options = options; + _options.HttpProtocols = ValidateAndNormalizeHttpProtocols(_options.HttpProtocols, _logger); + // capture the certificate now so it can't be switched after validation _serverCertificate = options.ServerCertificate; _serverCertificateSelector = options.ServerCertificateSelector; - if (_serverCertificate == null && _serverCertificateSelector == null) - { - throw new ArgumentException(CoreStrings.ServerCertificateRequired, nameof(options)); - } // If a selector is provided then ignore the cert, it may be a default cert. if (_serverCertificateSelector != null) @@ -84,13 +90,33 @@ public HttpsConnectionMiddleware(ConnectionDelegate next, HttpsConnectionAdapter { EnsureCertificateIsAllowedForServerAuth(_serverCertificate); } + + var remoteCertificateValidationCallback = _options.ClientCertificateMode == ClientCertificateMode.NoCertificate ? + (RemoteCertificateValidationCallback)null : RemoteCertificateValidationCallback; + + _sslStreamFactory = s => new SslStream(s, leaveInnerStreamOpen: false, userCertificateValidationCallback: remoteCertificateValidationCallback); + } + + internal HttpsConnectionMiddleware( + ConnectionDelegate next, + HttpsOptionsCallback httpsOptionsCallback, + object httpsOptionsCallbackState, + TimeSpan handshakeTimeout, + ILoggerFactory loggerFactory) + { + _next = next; + _handshakeTimeout = handshakeTimeout; + _logger = loggerFactory.CreateLogger(); + + _httpsOptionsCallback = httpsOptionsCallback; + _httpsOptionsCallbackState = httpsOptionsCallbackState; + _sslStreamFactory = s => new SslStream(s); } public async Task OnConnectionAsync(ConnectionContext context) { await Task.Yield(); - bool certificateRequired; if (context.Features.Get() != null) { await _next(context); @@ -101,152 +127,54 @@ public async Task OnConnectionAsync(ConnectionContext context) context.Features.Set(feature); context.Features.Set(feature); - var memoryPool = context.Features.Get()?.MemoryPool; - - var inputPipeOptions = new StreamPipeReaderOptions - ( - pool: memoryPool, - bufferSize: memoryPool.GetMinimumSegmentSize(), - minimumReadSize: memoryPool.GetMinimumAllocSize(), - leaveOpen: true - ); - - var outputPipeOptions = new StreamPipeWriterOptions - ( - pool: memoryPool, - leaveOpen: true - ); - - SslDuplexPipe sslDuplexPipe = null; - - if (_options.ClientCertificateMode == ClientCertificateMode.NoCertificate) - { - sslDuplexPipe = new SslDuplexPipe(context.Transport, inputPipeOptions, outputPipeOptions); - certificateRequired = false; - } - else - { - sslDuplexPipe = new SslDuplexPipe(context.Transport, inputPipeOptions, outputPipeOptions, s => new SslStream(s, - leaveInnerStreamOpen: false, - userCertificateValidationCallback: (sender, certificate, chain, sslPolicyErrors) => - { - if (certificate == null) - { - return _options.ClientCertificateMode != ClientCertificateMode.RequireCertificate; - } - - if (_options.ClientCertificateValidation == null) - { - if (sslPolicyErrors != SslPolicyErrors.None) - { - return false; - } - } - - var certificate2 = ConvertToX509Certificate2(certificate); - if (certificate2 == null) - { - return false; - } - - if (_options.ClientCertificateValidation != null) - { - if (!_options.ClientCertificateValidation(certificate2, chain, sslPolicyErrors)) - { - return false; - } - } - - return true; - })); - - certificateRequired = true; - } - + var sslDuplexPipe = CreateSslDuplexPipe(context.Transport, context.Features.Get()?.MemoryPool); var sslStream = sslDuplexPipe.Stream; - using (var cancellationTokeSource = new CancellationTokenSource(_options.HandshakeTimeout)) + try { - try + using var cancellationTokenSource = new CancellationTokenSource(_handshakeTimeout); + if (_httpsOptionsCallback is null) { - // Adapt to the SslStream signature - ServerCertificateSelectionCallback selector = null; - if (_serverCertificateSelector != null) - { - selector = (sender, name) => - { - feature.HostName = name; - context.Features.Set(sslStream); - var cert = _serverCertificateSelector(context, name); - if (cert != null) - { - EnsureCertificateIsAllowedForServerAuth(cert); - } - return cert; - }; - } - - var sslOptions = new SslServerAuthenticationOptions - { - ServerCertificate = _serverCertificate, - ServerCertificateSelectionCallback = selector, - ClientCertificateRequired = certificateRequired, - EnabledSslProtocols = _options.SslProtocols, - CertificateRevocationCheckMode = _options.CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, - ApplicationProtocols = new List() - }; - - // This is order sensitive - if ((_options.HttpProtocols & HttpProtocols.Http2) != 0) - { - sslOptions.ApplicationProtocols.Add(SslApplicationProtocol.Http2); - // https://tools.ietf.org/html/rfc7540#section-9.2.1 - sslOptions.AllowRenegotiation = false; - } - - if ((_options.HttpProtocols & HttpProtocols.Http1) != 0) - { - sslOptions.ApplicationProtocols.Add(SslApplicationProtocol.Http11); - } - - _options.OnAuthenticate?.Invoke(context, sslOptions); - - KestrelEventSource.Log.TlsHandshakeStart(context, sslOptions); - - await sslStream.AuthenticateAsServerAsync(sslOptions, cancellationTokeSource.Token); + await DoOptionsBasedHandshakeAsync(context, sslStream, feature, cancellationTokenSource.Token); } - catch (OperationCanceledException) + else { - KestrelEventSource.Log.TlsHandshakeFailed(context.ConnectionId); - KestrelEventSource.Log.TlsHandshakeStop(context, null); - - _logger.AuthenticationTimedOut(); - await sslStream.DisposeAsync(); - return; + var state = (this, context, feature); + await sslStream.AuthenticateAsServerAsync(ServerOptionsCallback, state, cancellationTokenSource.Token); } - catch (IOException ex) - { - KestrelEventSource.Log.TlsHandshakeFailed(context.ConnectionId); - KestrelEventSource.Log.TlsHandshakeStop(context, null); + } + catch (OperationCanceledException) + { + KestrelEventSource.Log.TlsHandshakeFailed(context.ConnectionId); + KestrelEventSource.Log.TlsHandshakeStop(context, null); - _logger.AuthenticationFailed(ex); - await sslStream.DisposeAsync(); - return; - } - catch (AuthenticationException ex) - { - KestrelEventSource.Log.TlsHandshakeFailed(context.ConnectionId); - KestrelEventSource.Log.TlsHandshakeStop(context, null); + _logger.AuthenticationTimedOut(); + await sslStream.DisposeAsync(); + return; + } + catch (IOException ex) + { + KestrelEventSource.Log.TlsHandshakeFailed(context.ConnectionId); + KestrelEventSource.Log.TlsHandshakeStop(context, null); + + _logger.AuthenticationFailed(ex); + await sslStream.DisposeAsync(); + return; + } + catch (AuthenticationException ex) + { + KestrelEventSource.Log.TlsHandshakeFailed(context.ConnectionId); + KestrelEventSource.Log.TlsHandshakeStop(context, null); - _logger.AuthenticationFailed(ex); + _logger.AuthenticationFailed(ex); - await sslStream.DisposeAsync(); - return; - } + await sslStream.DisposeAsync(); + return; } feature.ApplicationProtocol = sslStream.NegotiatedApplicationProtocol.Protocol; context.Features.Set(feature); + feature.ClientCertificate = ConvertToX509Certificate2(sslStream.RemoteCertificate); feature.CipherAlgorithm = sslStream.CipherAlgorithm; feature.CipherStrength = sslStream.CipherStrength; @@ -282,7 +210,135 @@ public async Task OnConnectionAsync(ConnectionContext context) } } - private static void EnsureCertificateIsAllowedForServerAuth(X509Certificate2 certificate) + private Task DoOptionsBasedHandshakeAsync(ConnectionContext context, SslStream sslStream, Core.Internal.TlsConnectionFeature feature, CancellationToken cancellationToken) + { + // Adapt to the SslStream signature + ServerCertificateSelectionCallback selector = null; + if (_serverCertificateSelector != null) + { + selector = (sender, name) => + { + feature.HostName = name; + context.Features.Set(sslStream); + var cert = _serverCertificateSelector(context, name); + if (cert != null) + { + EnsureCertificateIsAllowedForServerAuth(cert); + } + return cert; + }; + } + + var sslOptions = new SslServerAuthenticationOptions + { + ServerCertificate = _serverCertificate, + ServerCertificateSelectionCallback = selector, + ClientCertificateRequired = _options.ClientCertificateMode != ClientCertificateMode.NoCertificate, + EnabledSslProtocols = _options.SslProtocols, + CertificateRevocationCheckMode = _options.CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, + }; + + ConfigureAlpn(sslOptions, _options.HttpProtocols); + + _options.OnAuthenticate?.Invoke(context, sslOptions); + + KestrelEventSource.Log.TlsHandshakeStart(context, sslOptions); + + return sslStream.AuthenticateAsServerAsync(sslOptions, cancellationToken); + } + + internal static void ConfigureAlpn(SslServerAuthenticationOptions serverOptions, HttpProtocols httpProtocols) + { + serverOptions.ApplicationProtocols = new List(); + + // This is order sensitive + if ((httpProtocols & HttpProtocols.Http2) != 0) + { + serverOptions.ApplicationProtocols.Add(SslApplicationProtocol.Http2); + // https://tools.ietf.org/html/rfc7540#section-9.2.1 + serverOptions.AllowRenegotiation = false; + } + + if ((httpProtocols & HttpProtocols.Http1) != 0) + { + serverOptions.ApplicationProtocols.Add(SslApplicationProtocol.Http11); + } + } + + internal static bool RemoteCertificateValidationCallback( + ClientCertificateMode clientCertificateMode, + Func clientCertificateValidation, + X509Certificate certificate, + X509Chain chain, + SslPolicyErrors sslPolicyErrors) + { + if (certificate == null) + { + return clientCertificateMode != ClientCertificateMode.RequireCertificate; + } + + if (clientCertificateValidation == null) + { + if (sslPolicyErrors != SslPolicyErrors.None) + { + return false; + } + } + + var certificate2 = ConvertToX509Certificate2(certificate); + if (certificate2 == null) + { + return false; + } + + if (clientCertificateValidation != null) + { + if (!clientCertificateValidation(certificate2, chain, sslPolicyErrors)) + { + return false; + } + } + + return true; + } + + private bool RemoteCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) => + RemoteCertificateValidationCallback(_options.ClientCertificateMode, _options.ClientCertificateValidation, certificate, chain, sslPolicyErrors); + + private SslDuplexPipe CreateSslDuplexPipe(IDuplexPipe transport, MemoryPool memoryPool) + { + var inputPipeOptions = new StreamPipeReaderOptions + ( + pool: memoryPool, + bufferSize: memoryPool.GetMinimumSegmentSize(), + minimumReadSize: memoryPool.GetMinimumAllocSize(), + leaveOpen: true + ); + + var outputPipeOptions = new StreamPipeWriterOptions + ( + pool: memoryPool, + leaveOpen: true + ); + + return new SslDuplexPipe(transport, inputPipeOptions, outputPipeOptions, _sslStreamFactory); + } + + private static async ValueTask ServerOptionsCallback(SslStream sslStream, SslClientHelloInfo clientHelloInfo, object state, CancellationToken cancellationToken) + { + var (middleware, context, feature) = (ValueTuple)state; + + feature.HostName = clientHelloInfo.ServerName; + context.Features.Set(sslStream); + + var sslOptions = await middleware._httpsOptionsCallback(context, sslStream, clientHelloInfo, middleware._httpsOptionsCallbackState, cancellationToken); + + KestrelEventSource.Log.TlsHandshakeStart(context, sslOptions); + + return sslOptions; + } + + internal static void EnsureCertificateIsAllowedForServerAuth(X509Certificate2 certificate) { if (!CertificateLoader.IsCertificateAllowedForServerAuth(certificate)) { @@ -305,7 +361,30 @@ private static X509Certificate2 ConvertToX509Certificate2(X509Certificate certif return new X509Certificate2(certificate); } - private static bool IsWindowsVersionIncompatible() + internal static HttpProtocols ValidateAndNormalizeHttpProtocols(HttpProtocols httpProtocols, ILogger logger) + { + // This configuration will always fail per-request, preemptively fail it here. See HttpConnection.SelectProtocol(). + if (httpProtocols == HttpProtocols.Http2) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + throw new NotSupportedException(CoreStrings.Http2NoTlsOsx); + } + else if (_isWindowsVersionIncompatibleWithHttp2) + { + throw new NotSupportedException(CoreStrings.Http2NoTlsWin81); + } + } + else if (httpProtocols == HttpProtocols.Http1AndHttp2 && _isWindowsVersionIncompatibleWithHttp2) + { + logger.Http2DefaultCiphersInsufficient(); + return HttpProtocols.Http1; + } + + return httpProtocols; + } + + private static bool IsWindowsVersionIncompatibleWithHttp2() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -349,12 +428,12 @@ internal static class HttpsConnectionMiddlewareLoggerExtensions eventId: new EventId(4, "Http2DefaultCiphersInsufficient"), formatString: CoreStrings.Http2DefaultCiphersInsufficient); - public static void AuthenticationFailed(this ILogger logger, Exception exception) => _authenticationFailed(logger, exception); + public static void AuthenticationFailed(this ILogger logger, Exception exception) => _authenticationFailed(logger, exception); - public static void AuthenticationTimedOut(this ILogger logger) => _authenticationTimedOut(logger, null); + public static void AuthenticationTimedOut(this ILogger logger) => _authenticationTimedOut(logger, null); - public static void HttpsConnectionEstablished(this ILogger logger, string connectionId, SslProtocols sslProtocol) => _httpsConnectionEstablished(logger, connectionId, sslProtocol, null); + public static void HttpsConnectionEstablished(this ILogger logger, string connectionId, SslProtocols sslProtocol) => _httpsConnectionEstablished(logger, connectionId, sslProtocol, null); - public static void Http2DefaultCiphersInsufficient(this ILogger logger) => _http2DefaultCiphersInsufficient(logger, null); + public static void Http2DefaultCiphersInsufficient(this ILogger logger) => _http2DefaultCiphersInsufficient(logger, null); } } diff --git a/src/Servers/Kestrel/Core/test/KestrelServerOptionsTests.cs b/src/Servers/Kestrel/Core/test/KestrelServerOptionsTests.cs index 956859b27962..e96c2d1940ca 100644 --- a/src/Servers/Kestrel/Core/test/KestrelServerOptionsTests.cs +++ b/src/Servers/Kestrel/Core/test/KestrelServerOptionsTests.cs @@ -3,6 +3,11 @@ using System; using System.Net; +using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Moq; using Xunit; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests @@ -51,10 +56,36 @@ public void ConfigureEndpointDefaultsAppliesToNewEndpoints() Assert.Equal(HttpProtocols.Http2, options.CodeBackedListenOptions[3].Protocols); } + [Fact] + public void ConfigureThrowsInvalidOperationExceptionIfApplicationServicesIsNotSet() + { + var options = new KestrelServerOptions(); + Assert.Throws(() => options.Configure()); + } + + [Fact] + public void ConfigureThrowsInvalidOperationExceptionIfApplicationServicesDoesntHaveRequiredServices() + { + var options = new KestrelServerOptions + { + ApplicationServices = new ServiceCollection().BuildServiceProvider() + }; + + Assert.Throws(() => options.Configure()); + } + [Fact] public void CanCallListenAfterConfigure() { var options = new KestrelServerOptions(); + + // Ensure configure doesn't throw because of missing services. + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(Mock.Of()); + serviceCollection.AddSingleton(Mock.Of>()); + serviceCollection.AddSingleton(Mock.Of>()); + options.ApplicationServices = serviceCollection.BuildServiceProvider(); + options.Configure(); // This is a regression test to verify the Listen* methods don't throw a NullReferenceException if called after Configure(). @@ -63,7 +94,7 @@ public void CanCallListenAfterConfigure() } [Fact] - public void SettingRequestHeaderEncodingSelecterThrowsArgumentNullException() + public void SettingRequestHeaderEncodingSelecterToNullThrowsArgumentNullException() { var options = new KestrelServerOptions(); diff --git a/src/Servers/Kestrel/Core/test/KestrelServerTests.cs b/src/Servers/Kestrel/Core/test/KestrelServerTests.cs index 4f41ddbe1900..dac5a74c5324 100644 --- a/src/Servers/Kestrel/Core/test/KestrelServerTests.cs +++ b/src/Servers/Kestrel/Core/test/KestrelServerTests.cs @@ -13,9 +13,11 @@ using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; +using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -493,8 +495,9 @@ public async Task ReloadsOnConfigurationChangeWhenOptedIn() mockLoggerFactory.Setup(m => m.CreateLogger(It.IsAny())).Returns(Mock.Of()); var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton(mockLoggerFactory.Object); + serviceCollection.AddSingleton(Mock.Of()); serviceCollection.AddSingleton(Mock.Of>()); + serviceCollection.AddSingleton(Mock.Of>()); var options = new KestrelServerOptions { @@ -629,8 +632,9 @@ public async Task DoesNotReloadOnConfigurationChangeByDefault() mockLoggerFactory.Setup(m => m.CreateLogger(It.IsAny())).Returns(Mock.Of()); var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton(mockLoggerFactory.Object); + serviceCollection.AddSingleton(Mock.Of()); serviceCollection.AddSingleton(Mock.Of>()); + serviceCollection.AddSingleton(Mock.Of>()); var options = new KestrelServerOptions { diff --git a/src/Servers/Kestrel/Core/test/SniOptionsSelectorTests.cs b/src/Servers/Kestrel/Core/test/SniOptionsSelectorTests.cs new file mode 100644 index 000000000000..a2c2be973b99 --- /dev/null +++ b/src/Servers/Kestrel/Core/test/SniOptionsSelectorTests.cs @@ -0,0 +1,778 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO.Pipelines; +using System.Linq; +using System.Net.Security; +using System.Runtime.InteropServices; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Certificates; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests +{ + public class SniOptionsSelectorTests + { + private static X509Certificate2 _x509Certificate2 = TestResources.GetTestCertificate(); + + [Fact] + public void PrefersExactMatchOverWildcardPrefixOverWildcardOnly() + { + var sniDictionary = new Dictionary + { + { + "www.example.org", + new SniConfig + { + Certificate = new CertificateConfig + { + Path = "Exact" + } + } + }, + { + "*.example.org", + new SniConfig + { + Certificate = new CertificateConfig + { + Path = "WildcardPrefix" + } + } + }, + { + "*", + new SniConfig + { + Certificate = new CertificateConfig + { + Path = "WildcardOnly" + } + } + } + }; + + var mockCertificateConfigLoader = new MockCertificateConfigLoader(); + var pathDictionary = mockCertificateConfigLoader.CertToPathDictionary; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + mockCertificateConfigLoader, + fallbackHttpsOptions: new HttpsConnectionAdapterOptions(), + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var wwwSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + Assert.Equal("Exact", pathDictionary[wwwSubdomainOptions.ServerCertificate]); + + var baSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "b.a.example.org"); + Assert.Equal("WildcardPrefix", pathDictionary[baSubdomainOptions.ServerCertificate]); + + var aSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "a.example.org"); + Assert.Equal("WildcardPrefix", pathDictionary[aSubdomainOptions.ServerCertificate]); + + // "*.example.org" is preferred over "*", but "*.example.org" doesn't match "example.org". + // REVIEW: Are we OK with "example.org" matching "*" instead of "*.example.org"? It feels annoying to me to have to configure example.org twice. + // Unfortunately, the alternative would have "a.example.org" match "*.a.example.org" before "*.example.org", and that just seems wrong. + var noSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "example.org"); + Assert.Equal("WildcardOnly", pathDictionary[noSubdomainOptions.ServerCertificate]); + + var anotherTldOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "dot.net"); + Assert.Equal("WildcardOnly", pathDictionary[anotherTldOptions.ServerCertificate]); + } + + [Fact] + public void PerfersLongerWildcardPrefixOverShorterWildcardPrefix() + { + var sniDictionary = new Dictionary + { + { + "*.a.example.org", + new SniConfig + { + Certificate = new CertificateConfig + { + Path = "Long" + } + } + }, + { + "*.example.org", + new SniConfig + { + Certificate = new CertificateConfig + { + Path = "Short" + } + } + } + }; + + var mockCertificateConfigLoader = new MockCertificateConfigLoader(); + var pathDictionary = mockCertificateConfigLoader.CertToPathDictionary; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + mockCertificateConfigLoader, + fallbackHttpsOptions: new HttpsConnectionAdapterOptions(), + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var baSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "b.a.example.org"); + Assert.Equal("Long", pathDictionary[baSubdomainOptions.ServerCertificate]); + + // "*.a.example.org" is preferred over "*.example.org", but "a.example.org" doesn't match "*.a.example.org". + var aSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "a.example.org"); + Assert.Equal("Short", pathDictionary[aSubdomainOptions.ServerCertificate]); + } + + [Fact] + public void ServerNameMatchingIsCaseInsensitive() + { + var sniDictionary = new Dictionary + { + { + "Www.Example.Org", + new SniConfig + { + Certificate = new CertificateConfig + { + Path = "Exact" + } + } + }, + { + "*.Example.Org", + new SniConfig + { + Certificate = new CertificateConfig + { + Path = "WildcardPrefix" + } + } + } + }; + + var mockCertificateConfigLoader = new MockCertificateConfigLoader(); + var pathDictionary = mockCertificateConfigLoader.CertToPathDictionary; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + mockCertificateConfigLoader, + fallbackHttpsOptions: new HttpsConnectionAdapterOptions(), + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var wwwSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "wWw.eXample.oRg"); + Assert.Equal("Exact", pathDictionary[wwwSubdomainOptions.ServerCertificate]); + + var baSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "B.a.eXample.oRg"); + Assert.Equal("WildcardPrefix", pathDictionary[baSubdomainOptions.ServerCertificate]); + + var aSubdomainOptions = sniOptionsSelector.GetOptions(new MockConnectionContext(), "A.eXample.oRg"); + Assert.Equal("WildcardPrefix", pathDictionary[aSubdomainOptions.ServerCertificate]); + } + + [Fact] + public void GetOptionsThrowsAnAuthenticationExceptionIfThereIsNoMatchingSniSection() + { + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + new Dictionary(), + new MockCertificateConfigLoader(), + fallbackHttpsOptions: new HttpsConnectionAdapterOptions(), + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var authExWithServerName = Assert.Throws(() => sniOptionsSelector.GetOptions(new MockConnectionContext(), "example.org")); + Assert.Equal(CoreStrings.FormatSniNotConfiguredForServerName("example.org", "TestEndpointName"), authExWithServerName.Message); + + var authExWithoutServerName = Assert.Throws(() => sniOptionsSelector.GetOptions(new MockConnectionContext(), null)); + Assert.Equal(CoreStrings.FormatSniNotConfiguredToAllowNoServerName("TestEndpointName"), authExWithoutServerName.Message); + } + + [Fact] + public void WildcardOnlyMatchesNullServerNameDueToNoAlpn() + { + var sniDictionary = new Dictionary + { + { + "*", + new SniConfig + { + Certificate = new CertificateConfig + { + Path = "WildcardOnly" + } + } + } + }; + + var mockCertificateConfigLoader = new MockCertificateConfigLoader(); + var pathDictionary = mockCertificateConfigLoader.CertToPathDictionary; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + mockCertificateConfigLoader, + fallbackHttpsOptions: new HttpsConnectionAdapterOptions(), + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), null); + Assert.Equal("WildcardOnly", pathDictionary[options.ServerCertificate]); + } + + [Fact] + public void CachesSslServerAuthenticationOptions() + { + var sniDictionary = new Dictionary + { + { + "www.example.org", + new SniConfig + { + Certificate = new CertificateConfig() + } + } + }; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + new MockCertificateConfigLoader(), + fallbackHttpsOptions: new HttpsConnectionAdapterOptions(), + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var options1 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var options2 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + Assert.Same(options1, options2); + } + + [Fact] + public void ClonesSslServerAuthenticationOptionsIfAnOnAuthenticateCallbackIsDefined() + { + var sniDictionary = new Dictionary + { + { + "www.example.org", + new SniConfig + { + Certificate = new CertificateConfig() + } + } + }; + + SslServerAuthenticationOptions lastSeenSslOptions = null; + + var fallbackOptions = new HttpsConnectionAdapterOptions + { + OnAuthenticate = (context, sslOptions) => + { + lastSeenSslOptions = sslOptions; + } + }; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + new MockCertificateConfigLoader(), + fallbackOptions, + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var options1 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + Assert.Same(lastSeenSslOptions, options1); + + var options2 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + Assert.Same(lastSeenSslOptions, options2); + + Assert.NotSame(options1, options2); + } + + [Fact] + public void ClonesSslServerAuthenticationOptionsIfTheFallbackServerCertificateSelectorIsUsed() + { + var sniDictionary = new Dictionary + { + { + "selector.example.org", + new SniConfig() + }, + { + "config.example.org", + new SniConfig + { + Certificate = new CertificateConfig() + } + } + }; + + var selectorCertificate = _x509Certificate2; + + var fallbackOptions = new HttpsConnectionAdapterOptions + { + ServerCertificate = new X509Certificate2(), + ServerCertificateSelector = (context, serverName) => selectorCertificate + }; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + new MockCertificateConfigLoader(), + fallbackOptions, + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var selectorOptions1 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "selector.example.org"); + Assert.Same(selectorCertificate, selectorOptions1.ServerCertificate); + + var selectorOptions2 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "selector.example.org"); + Assert.Same(selectorCertificate, selectorOptions2.ServerCertificate); + + // The SslServerAuthenticationOptions were cloned because the cert came from the ServerCertificateSelector fallback. + Assert.NotSame(selectorOptions1, selectorOptions2); + + var configOptions1 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "config.example.org"); + Assert.NotSame(selectorCertificate, configOptions1.ServerCertificate); + + var configOptions2 = sniOptionsSelector.GetOptions(new MockConnectionContext(), "config.example.org"); + Assert.NotSame(selectorCertificate, configOptions2.ServerCertificate); + + // The SslServerAuthenticationOptions don't need to be cloned if a static cert is defined in config for the given server name. + Assert.Same(configOptions1, configOptions2); + } + + [Fact] + public void ConstructorThrowsInvalidOperationExceptionIfNoCertificateDefiniedInConfigOrFallback() + { + var sniDictionary = new Dictionary + { + { "www.example.org", new SniConfig() } + }; + + var ex = Assert.Throws( + () => new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + new MockCertificateConfigLoader(), + fallbackHttpsOptions: new HttpsConnectionAdapterOptions(), + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>())); + + Assert.Equal(CoreStrings.NoCertSpecifiedNoDevelopmentCertificateFound, ex.Message); + } + + [Fact] + public void FallsBackToHttpsConnectionAdapterCertificate() + { + var sniDictionary = new Dictionary + { + { "www.example.org", new SniConfig() } + }; + + var fallbackOptions = new HttpsConnectionAdapterOptions + { + ServerCertificate = new X509Certificate2() + }; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + new MockCertificateConfigLoader(), + fallbackOptions, + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + Assert.Same(fallbackOptions.ServerCertificate, options.ServerCertificate); + } + + [Fact] + public void FallsBackToHttpsConnectionAdapterServerCertificateSelectorOverServerCertificate() + { + var sniDictionary = new Dictionary + { + { "www.example.org", new SniConfig() } + }; + + var selectorCertificate = _x509Certificate2; + + var fallbackOptions = new HttpsConnectionAdapterOptions + { + ServerCertificate = new X509Certificate2(), + ServerCertificateSelector = (context, serverName) => selectorCertificate + }; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + new MockCertificateConfigLoader(), + fallbackOptions, + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + Assert.Same(selectorCertificate, options.ServerCertificate); + } + + [Fact] + public void PrefersHttpProtocolsDefinedInSniConfig() + { + var sniDictionary = new Dictionary + { + { + "www.example.org", + new SniConfig + { + Protocols = HttpProtocols.None, + Certificate = new CertificateConfig() + } + } + }; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + new MockCertificateConfigLoader(), + new HttpsConnectionAdapterOptions(), + fallbackHttpProtocols: HttpProtocols.Http1, + logger: Mock.Of>()); + + var mockConnectionContext = new MockConnectionContext(); + sniOptionsSelector.GetOptions(mockConnectionContext, "www.example.org"); + + var httpProtocolsFeature = mockConnectionContext.Features.Get(); + Assert.NotNull(httpProtocolsFeature); + Assert.Equal(HttpProtocols.None, httpProtocolsFeature.HttpProtocols); + } + + [Fact] + public void ConfiguresAlpnBasedOnConfiguredHttpProtocols() + { + var sniDictionary = new Dictionary + { + { + "www.example.org", + new SniConfig + { + // I'm not using Http1AndHttp2 or Http2 because I don't want to account for + // validation and normalization. Other tests cover that. + Protocols = HttpProtocols.Http1, + Certificate = new CertificateConfig() + } + } + }; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + new MockCertificateConfigLoader(), + new HttpsConnectionAdapterOptions(), + fallbackHttpProtocols: HttpProtocols.None, + logger: Mock.Of>()); + + var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + var alpnList = options.ApplicationProtocols; + + Assert.NotNull(alpnList); + var protocol = Assert.Single(alpnList); + Assert.Equal(SslApplicationProtocol.Http11, protocol); + } + + [Fact] + public void FallsBackToFallbackHttpProtocols() + { + var sniDictionary = new Dictionary + { + { + "www.example.org", + new SniConfig + { + Certificate = new CertificateConfig() + } + } + }; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + new MockCertificateConfigLoader(), + new HttpsConnectionAdapterOptions(), + fallbackHttpProtocols: HttpProtocols.Http1, + logger: Mock.Of>()); + + var mockConnectionContext = new MockConnectionContext(); + sniOptionsSelector.GetOptions(mockConnectionContext, "www.example.org"); + + var httpProtocolsFeature = mockConnectionContext.Features.Get(); + Assert.NotNull(httpProtocolsFeature); + Assert.Equal(HttpProtocols.Http1, httpProtocolsFeature.HttpProtocols); + } + + [Fact] + public void PrefersSslProtocolsDefinedInSniConfig() + { + var sniDictionary = new Dictionary + { + { + "www.example.org", + new SniConfig + { + SslProtocols = SslProtocols.Tls13 | SslProtocols.Tls11, + Certificate = new CertificateConfig() + } + } + }; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + new MockCertificateConfigLoader(), + new HttpsConnectionAdapterOptions + { + SslProtocols = SslProtocols.Tls13 + }, + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + Assert.Equal(SslProtocols.Tls13 | SslProtocols.Tls11, options.EnabledSslProtocols); + } + + [Fact] + public void FallsBackToFallbackSslProtocols() + { + var sniDictionary = new Dictionary + { + { + "www.example.org", + new SniConfig + { + Certificate = new CertificateConfig() + } + } + }; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + new MockCertificateConfigLoader(), + new HttpsConnectionAdapterOptions + { + SslProtocols = SslProtocols.Tls13 + }, + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + Assert.Equal(SslProtocols.Tls13, options.EnabledSslProtocols); + } + + + [Fact] + public void PrefersClientCertificateModeDefinedInSniConfig() + { + var sniDictionary = new Dictionary + { + { + "www.example.org", + new SniConfig + { + ClientCertificateMode = ClientCertificateMode.RequireCertificate, + Certificate = new CertificateConfig() + } + } + }; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + new MockCertificateConfigLoader(), + new HttpsConnectionAdapterOptions + { + ClientCertificateMode = ClientCertificateMode.AllowCertificate + }, + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + + Assert.True(options.ClientCertificateRequired); + + Assert.NotNull(options.RemoteCertificateValidationCallback); + // The RemoteCertificateValidationCallback should first check if the certificate is null and return false since it's required. + Assert.False(options.RemoteCertificateValidationCallback(sender: null, certificate: null, chain: null, SslPolicyErrors.None)); + } + + [Fact] + public void FallsBackToFallbackClientCertificateMode() + { + var sniDictionary = new Dictionary + { + { + "www.example.org", + new SniConfig + { + Certificate = new CertificateConfig() + } + } + }; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + new MockCertificateConfigLoader(), + new HttpsConnectionAdapterOptions + { + ClientCertificateMode = ClientCertificateMode.AllowCertificate + }, + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var options = sniOptionsSelector.GetOptions(new MockConnectionContext(), "www.example.org"); + + // Despite the confusing name, ClientCertificateRequired being true simply requests a certificate from the client, but doesn't require it. + Assert.True(options.ClientCertificateRequired); + + Assert.NotNull(options.RemoteCertificateValidationCallback); + // The RemoteCertificateValidationCallback should see we're in the AllowCertificate mode and return true. + Assert.True(options.RemoteCertificateValidationCallback(sender: null, certificate: null, chain: null, SslPolicyErrors.None)); + } + + [Fact] + public void CloneSslOptionsClonesAllProperties() + { + var propertyNames = typeof(SslServerAuthenticationOptions).GetProperties().Select(property => property.Name).ToList(); + + CipherSuitesPolicy cipherSuitesPolicy = null; + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + try + { + // The CipherSuitesPolicy ctor throws a PlatformNotSupportedException on Windows. + cipherSuitesPolicy = new CipherSuitesPolicy(new[] { TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 }); + } + catch (PlatformNotSupportedException) + { + // The CipherSuitesPolicy ctor throws a PlatformNotSupportedException on Ubuntu 16.04. + // I don't know exactly which other distros/versions throw PNEs, but it isn't super relevant to this test, + // so let's just swallow this exception. + } + } + + // Set options properties to non-default values to verify they're copied. + var options = new SslServerAuthenticationOptions + { + // Defaults to true + AllowRenegotiation = false, + // Defaults to null + ApplicationProtocols = new List { SslApplicationProtocol.Http2 }, + // Defaults to X509RevocationMode.NoCheck + CertificateRevocationCheckMode = X509RevocationMode.Offline, + // Defaults to null + CipherSuitesPolicy = cipherSuitesPolicy, + // Defaults to false + ClientCertificateRequired = true, + // Defaults to SslProtocols.None + EnabledSslProtocols = SslProtocols.Tls13 | SslProtocols.Tls11, + // Defaults to EncryptionPolicy.RequireEncryption + EncryptionPolicy = EncryptionPolicy.NoEncryption, + // Defaults to null + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true, + // Defaults to null + ServerCertificate = new X509Certificate2(), + // Defaults to null + ServerCertificateContext = SslStreamCertificateContext.Create(_x509Certificate2, additionalCertificates: null, offline: true), + // Defaults to null + ServerCertificateSelectionCallback = (sender, serverName) => null, + }; + + var clonedOptions = SniOptionsSelector.CloneSslOptions(options); + + Assert.NotSame(options, clonedOptions); + + Assert.Equal(options.AllowRenegotiation, clonedOptions.AllowRenegotiation); + Assert.True(propertyNames.Remove(nameof(options.AllowRenegotiation))); + + // Ensure the List is also cloned since it could be modified by a user callback. + Assert.NotSame(options.ApplicationProtocols, clonedOptions.ApplicationProtocols); + Assert.Equal(Assert.Single(options.ApplicationProtocols), Assert.Single(clonedOptions.ApplicationProtocols)); + Assert.True(propertyNames.Remove(nameof(options.ApplicationProtocols))); + + Assert.Equal(options.CertificateRevocationCheckMode, clonedOptions.CertificateRevocationCheckMode); + Assert.True(propertyNames.Remove(nameof(options.CertificateRevocationCheckMode))); + + Assert.Same(options.CipherSuitesPolicy, clonedOptions.CipherSuitesPolicy); + Assert.True(propertyNames.Remove(nameof(options.CipherSuitesPolicy))); + + Assert.Equal(options.ClientCertificateRequired, clonedOptions.ClientCertificateRequired); + Assert.True(propertyNames.Remove(nameof(options.ClientCertificateRequired))); + + Assert.Equal(options.EnabledSslProtocols, clonedOptions.EnabledSslProtocols); + Assert.True(propertyNames.Remove(nameof(options.EnabledSslProtocols))); + + Assert.Equal(options.EncryptionPolicy, clonedOptions.EncryptionPolicy); + Assert.True(propertyNames.Remove(nameof(options.EncryptionPolicy))); + + Assert.Same(options.RemoteCertificateValidationCallback, clonedOptions.RemoteCertificateValidationCallback); + Assert.True(propertyNames.Remove(nameof(options.RemoteCertificateValidationCallback))); + + // Technically the ServerCertificate could be reset/reimported, but I'm hoping this is uncommon. Trying to clone the certificate and/or context seems risky. + Assert.Same(options.ServerCertificate, clonedOptions.ServerCertificate); + Assert.True(propertyNames.Remove(nameof(options.ServerCertificate))); + + Assert.Same(options.ServerCertificateContext, clonedOptions.ServerCertificateContext); + Assert.True(propertyNames.Remove(nameof(options.ServerCertificateContext))); + + Assert.Same(options.ServerCertificateSelectionCallback, clonedOptions.ServerCertificateSelectionCallback); + Assert.True(propertyNames.Remove(nameof(options.ServerCertificateSelectionCallback))); + + // Ensure we've checked every property. When new properties get added, we'll have to update this test along with the CloneSslOptions implementation. + Assert.Empty(propertyNames); + } + + private class MockCertificateConfigLoader : ICertificateConfigLoader + { + public Dictionary CertToPathDictionary { get; } = new Dictionary(ReferenceEqualityComparer.Instance); + + public bool IsTestMock => true; + + public X509Certificate2 LoadCertificate(CertificateConfig certInfo, string endpointName) + { + if (certInfo is null) + { + return null; + } + + var cert = new X509Certificate2(); + CertToPathDictionary.Add(cert, certInfo.Path); + return cert; + } + } + + private class MockConnectionContext : ConnectionContext + { + public override IDuplexPipe Transport { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public override string ConnectionId { get; set; } = "MockConnectionId"; + public override IFeatureCollection Features { get; } = new FeatureCollection(); + public override IDictionary Items { get; set; } = new Dictionary(); + } + } +} diff --git a/src/Servers/Kestrel/Kestrel/test/ConfigurationReaderTests.cs b/src/Servers/Kestrel/Kestrel/test/ConfigurationReaderTests.cs index 6b24b86b62d2..f71286354736 100644 --- a/src/Servers/Kestrel/Kestrel/test/ConfigurationReaderTests.cs +++ b/src/Servers/Kestrel/Kestrel/test/ConfigurationReaderTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Security.Authentication; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; using Microsoft.AspNetCore.Server.Kestrel.Https; @@ -273,6 +274,65 @@ public void ReadEndpointWithNoSslProtocolSettings_ReturnsNull() Assert.Null(endpoint.SslProtocols); } + [Fact] + public void ReadEndpointWithEmptySniSection_ReturnsEmptyCollection() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:End1:Url", "http://*:5001"), + }).Build(); + + var reader = new ConfigurationReader(config); + + var endpoint = reader.Endpoints.First(); + Assert.NotNull(endpoint.Sni); + Assert.False(endpoint.Sni.Any()); + } + + [Fact] + public void ReadEndpointWithEmptySniKey_Throws() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:End1:Url", "http://*:5001"), + new KeyValuePair("Endpoints:End1:Sni::Protocols", "Http1"), + }).Build(); + + var reader = new ConfigurationReader(config); + var end1Ex = Assert.Throws(() => reader.Endpoints); + + Assert.Equal(CoreStrings.FormatSniNameCannotBeEmpty("End1"), end1Ex.Message); + } + + [Fact] + public void ReadEndpointWithSniConfigured_ReturnsCorrectValue() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:End1:Url", "http://*:5001"), + new KeyValuePair("Endpoints:End1:Sni:*.example.org:Protocols", "Http1"), + new KeyValuePair("Endpoints:End1:Sni:*.example.org:SslProtocols:0", "Tls12"), + new KeyValuePair("Endpoints:End1:Sni:*.example.org:Certificate:Path", "/path/cert.pfx"), + new KeyValuePair("Endpoints:End1:Sni:*.example.org:Certificate:Password", "certpassword"), + new KeyValuePair("Endpoints:End1:SNI:*.example.org:ClientCertificateMode", "AllowCertificate"), + }).Build(); + + var reader = new ConfigurationReader(config); + + static void VerifySniConfig(SniConfig config) + { + Assert.NotNull(config); + + Assert.Equal(HttpProtocols.Http1, config.Protocols); + Assert.Equal(SslProtocols.Tls12, config.SslProtocols); + Assert.Equal("/path/cert.pfx", config.Certificate.Path); + Assert.Equal("certpassword", config.Certificate.Password); + Assert.Equal(ClientCertificateMode.AllowCertificate, config.ClientCertificateMode); + } + + VerifySniConfig(reader.Endpoints.First().Sni["*.Example.org"]); + } + [Fact] public void ReadEndpointDefaultsWithSingleSslProtocolSet_ReturnsCorrectValue() { diff --git a/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs b/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs index 2e71cdb2239c..d78825eeb6dc 100644 --- a/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs +++ b/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs @@ -8,7 +8,6 @@ using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using System.Text; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Https; @@ -433,6 +432,88 @@ public void ConfigureEndpointDevelopmentCertificateGetsIgnoredIfPfxFileDoesNotEx } } + [Fact] + public void ConfigureEndpoint_ThrowsWhen_HttpsConfigIsDeclaredInNonHttpsEndpoints() + { + var serverOptions = CreateServerOptions(); + + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:End1:Url", "http://*:5001"), + // We shouldn't need to specify a real cert, because KestrelConfigurationLoader should check whether the endpoint requires a cert before trying to load it. + new KeyValuePair("Endpoints:End1:Certificate:Path", "fakecert.pfx"), + }).Build(); + + var ex = Assert.Throws(() => serverOptions.Configure(config).Load()); + Assert.Equal(CoreStrings.FormatEndpointHasUnusedHttpsConfig("End1", "Certificate"), ex.Message); + + config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:End1:Url", "http://*:5001"), + new KeyValuePair("Endpoints:End1:Certificate:Subject", "example.org"), + }).Build(); + + ex = Assert.Throws(() => serverOptions.Configure(config).Load()); + Assert.Equal(CoreStrings.FormatEndpointHasUnusedHttpsConfig("End1", "Certificate"), ex.Message); + + config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:End1:Url", "http://*:5001"), + new KeyValuePair("Endpoints:End1:ClientCertificateMode", ClientCertificateMode.RequireCertificate.ToString()), + }).Build(); + + ex = Assert.Throws(() => serverOptions.Configure(config).Load()); + Assert.Equal(CoreStrings.FormatEndpointHasUnusedHttpsConfig("End1", "ClientCertificateMode"), ex.Message); + + config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:End1:Url", "http://*:5001"), + new KeyValuePair("Endpoints:End1:SslProtocols:0", SslProtocols.Tls13.ToString()), + }).Build(); + + ex = Assert.Throws(() => serverOptions.Configure(config).Load()); + Assert.Equal(CoreStrings.FormatEndpointHasUnusedHttpsConfig("End1", "SslProtocols"), ex.Message); + + config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:End1:Url", "http://*:5001"), + new KeyValuePair("Endpoints:End1:Sni:Protocols", HttpProtocols.Http1.ToString()), + }).Build(); + + ex = Assert.Throws(() => serverOptions.Configure(config).Load()); + Assert.Equal(CoreStrings.FormatEndpointHasUnusedHttpsConfig("End1", "Sni"), ex.Message); + } + + [Fact] + public void ConfigureEndpoint_DoesNotThrowWhen_HttpsConfigIsDeclaredInEndpointDefaults() + { + var serverOptions = CreateServerOptions(); + + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:End1:Url", "http://*:5001"), + new KeyValuePair("EndpointDefaults:ClientCertificateMode", ClientCertificateMode.RequireCertificate.ToString()), + }).Build(); + + var (_, endpointsToStart) = serverOptions.Configure(config).Reload(); + var end1 = Assert.Single(endpointsToStart); + Assert.NotNull(end1?.EndpointConfig); + Assert.Null(end1.EndpointConfig.ClientCertificateMode); + + serverOptions = CreateServerOptions(); + + config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:End1:Url", "http://*:5001"), + new KeyValuePair("EndpointDefaults:SslProtocols:0", SslProtocols.Tls13.ToString()), + }).Build(); + + (_, endpointsToStart) = serverOptions.Configure(config).Reload(); + end1 = Assert.Single(endpointsToStart); + Assert.NotNull(end1?.EndpointConfig); + Assert.Null(end1.EndpointConfig.SslProtocols); + } + [ConditionalTheory] [InlineData("http1", HttpProtocols.Http1)] // [InlineData("http2", HttpProtocols.Http2)] // Not supported due to missing ALPN support. https://github.com/dotnet/corefx/issues/33016 @@ -673,7 +754,6 @@ public void DefaultEndpointConfigureSection_CanSetSslProtocols() Assert.True(ran1); } - [Fact] public void DefaultEndpointConfigureSection_ConfigureHttpsDefaultsCanOverrideSslProtocols() { @@ -748,6 +828,36 @@ public void EndpointConfigureSection_CanSetClientCertificateMode() Assert.True(ran2); } + + [Fact] + public void EndpointConfigureSection_CanConfigureSni() + { + var serverOptions = CreateServerOptions(); + var certPath = Path.Combine("shared", "TestCertificates", "https-ecdsa.pem"); + var keyPath = Path.Combine("shared", "TestCertificates", "https-ecdsa.key"); + + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:End1:Url", "https://*:5001"), + new KeyValuePair("Endpoints:End1:Sni:*.example.org:Protocols", HttpProtocols.None.ToString()), + new KeyValuePair("Endpoints:End1:Sni:*.example.org:SslProtocols:0", SslProtocols.Tls13.ToString()), + new KeyValuePair("Endpoints:End1:Sni:*.example.org:ClientCertificateMode", ClientCertificateMode.RequireCertificate.ToString()), + new KeyValuePair("Endpoints:End1:Sni:*.example.org:Certificate:Path", certPath), + new KeyValuePair("Endpoints:End1:Sni:*.example.org:Certificate:KeyPath", keyPath), + }).Build(); + + var (_, endpointsToStart) = serverOptions.Configure(config).Reload(); + var end1 = Assert.Single(endpointsToStart); + var (name, sniConfig) = Assert.Single(end1?.EndpointConfig?.Sni); + + Assert.Equal("*.example.org", name); + Assert.Equal(HttpProtocols.None, sniConfig.Protocols); + Assert.Equal(SslProtocols.Tls13, sniConfig.SslProtocols); + Assert.Equal(ClientCertificateMode.RequireCertificate, sniConfig.ClientCertificateMode); + Assert.Equal(certPath, sniConfig.Certificate.Path); + Assert.Equal(keyPath, sniConfig.Certificate.KeyPath); + } + [Fact] public void EndpointConfigureSection_CanOverrideClientCertificateModeFromConfigureHttpsDefaults() { @@ -803,7 +913,6 @@ public void DefaultEndpointConfigureSection_CanSetClientCertificateMode() Assert.True(ran1); } - [Fact] public void DefaultEndpointConfigureSection_ConfigureHttpsDefaultsCanOverrideClientCertificateMode() { diff --git a/src/Servers/Kestrel/samples/SampleApp/appsettings.Production.json b/src/Servers/Kestrel/samples/SampleApp/appsettings.Production.json index 71c9c03be034..91283390ad19 100644 --- a/src/Servers/Kestrel/samples/SampleApp/appsettings.Production.json +++ b/src/Servers/Kestrel/samples/SampleApp/appsettings.Production.json @@ -1,9 +1,17 @@ -{ +{ "Kestrel": { "Endpoints": { "NamedEndpoint": { "Url": "http://*:6000" }, "NamedHttpsEndpoint": { "Url": "https://*:6443", + "Sni": { + "localhost": { + "Protocols": "Http1AndHttp2" + }, + "*": { + "SslProtocols": [ "Tls12", "Tls13" ] + } + } } } } diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs index 59c7222df767..0b65712c5efd 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs @@ -24,7 +24,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Logging; using Moq; using Xunit; @@ -69,9 +69,14 @@ public async Task CanReadAndWriteWithHttpsConnectionMiddlewareWithPemCertificate var env = new Mock(); env.SetupGet(e => e.ContentRootPath).Returns(Directory.GetCurrentDirectory()); - options.ApplicationServices = new ServiceCollection().AddSingleton(env.Object).AddLogging().BuildServiceProvider(); - var loader = new KestrelConfigurationLoader(options, configuration, reloadOnChange: false); + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + options.ApplicationServices = serviceProvider; + + var logger = serviceProvider.GetRequiredService>(); + var httpsLogger = serviceProvider.GetRequiredService>(); + var loader = new KestrelConfigurationLoader(options, configuration, env.Object, reloadOnChange: false, logger, httpsLogger); loader.Load(); + void ConfigureListenOptions(ListenOptions listenOptions) { listenOptions.KestrelServerOptions = options; @@ -160,6 +165,38 @@ void ConfigureListenOptions(ListenOptions listenOptions) } } + [Fact] + [QuarantinedTest("https://github.com/dotnet/runtime/issues/40402")] + public async Task ClientCertificateRequiredConfiguredInCallbackContinuesWhenNoCertificate() + { + void ConfigureListenOptions(ListenOptions listenOptions) + { + listenOptions.UseHttps((connection, stream, clientHelloInfo, state, cancellationToken) => + new ValueTask(new SslServerAuthenticationOptions + { + ServerCertificate = _x509Certificate2, + // From the API Docs: "Note that this is only a request -- + // if no certificate is provided, the server still accepts the connection request." + // Not to mention this is equivalent to the test above. + ClientCertificateRequired = true, + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck + }), state: null, HttpsConnectionAdapterOptions.DefaultHandshakeTimeout); + } + + await using (var server = new TestServer(context => + { + var tlsFeature = context.Features.Get(); + Assert.NotNull(tlsFeature); + Assert.Null(tlsFeature.ClientCertificate); + return context.Response.WriteAsync("hello world"); + }, new TestServiceContext(LoggerFactory), ConfigureListenOptions)) + { + var result = await server.HttpClientSlim.GetStringAsync($"https://localhost:{server.Port}/", validateCertificate: false); + Assert.Equal("hello world", result); + } + } + [Fact] public void ThrowsWhenNoServerCertificateIsProvided() {