diff --git a/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs b/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs index 055c3d23d..f3866f37c 100644 --- a/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs +++ b/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs @@ -486,8 +486,14 @@ protected virtual string GetDefaultClientName() => public virtual bool GetDefaultSsl(EndPointCollection endPoints) => false; /// - /// Gets the SSL Host to check for when connecting to endpoints (customizable in case of internal certificate shenanigans. + /// Gets the SSL host to infer when is not explicitly set, for + /// endpoints that don't already carry their own host name (customizable in case of internal certificate shenanigans). /// + /// + /// Only consulted for non- connections - a always uses its + /// own instead, so in practice this applies to IP endpoints, such as ones + /// discovered via cluster topology. + /// /// The configured endpoints to determine SSL host from (e.g. from the port). /// The common host, if any, detected from the endpoint collection. public virtual string? GetSslHostFromEndpoints(EndPointCollection endPoints) diff --git a/src/StackExchange.Redis/Configuration/LoggingTunnel.cs b/src/StackExchange.Redis/Configuration/LoggingTunnel.cs index a76231cd7..3fb023c9f 100644 --- a/src/StackExchange.Redis/Configuration/LoggingTunnel.cs +++ b/src/StackExchange.Redis/Configuration/LoggingTunnel.cs @@ -407,11 +407,7 @@ private async Task TlsHandshakeAsync(Stream stream, EndPoint endpoint) #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously { // mirrors TLS handshake from PhysicalConnection, but wouldn't help to share code here - var host = _options.SslHost; - if (host.IsNullOrWhiteSpace()) - { - host = Format.ToStringHostOnly(endpoint); - } + var host = _options.ResolveTlsHostName(endpoint); var ssl = new SslStream( innerStream: stream, diff --git a/src/StackExchange.Redis/Configuration/TlsOptions.cs b/src/StackExchange.Redis/Configuration/TlsOptions.cs index 2e0d9f533..52798f694 100644 --- a/src/StackExchange.Redis/Configuration/TlsOptions.cs +++ b/src/StackExchange.Redis/Configuration/TlsOptions.cs @@ -96,12 +96,10 @@ public LocalCertificateSelectionCallback? CertificateSelectionCallback #endif /// - /// The TLS host name to use for the given endpoint: the configured if there is - /// one, otherwise the host portion of the endpoint - which is what the library's own TLS path does. + /// The TLS host name to use for the given endpoint. An explicitly configured always + /// wins. Otherwise, a uses its own host, and an address endpoint uses the inferred + /// configuration host when available before falling back to its address. /// public string ResolveHost(EndPoint endpoint) - { - var host = SslHost; - return host.IsNullOrWhiteSpace() ? Format.ToStringHostOnly(endpoint) : host!; - } + => _options?.ResolveTlsHostName(endpoint) ?? Format.ToStringHostOnly(endpoint); } diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index 5b75fd4ed..75a23c630 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -957,14 +957,29 @@ public bool Ssl } /// - /// The target-host to use when validating SSL certificate; setting a value here enables SSL mode. + /// The target host to use for SNI and certificate validation; setting a value here enables SSL mode. /// + /// + /// When explicitly configured, this overrides the host for every endpoint. When unset, connections to a + /// use that endpoint's host, allowing hostname-routed clusters to use a distinct + /// SNI name for each node. + /// public string? SslHost { - get => sslHost ?? Defaults.GetSslHostFromEndpoints(EndPoints); + get => sslHost; set => sslHost = value; } + internal string ResolveTlsHostName(EndPoint endpoint) + { + var host = sslHost; + if (host.IsNullOrWhiteSpace() && endpoint is not DnsEndPoint) + { + host = Defaults.GetSslHostFromEndpoints(EndPoints); + } + return Format.GetTlsHostName(endpoint, host); + } + /// /// Configures which SSL/TLS protocols should be allowed. If not set, defaults are chosen by the .NET framework. /// diff --git a/src/StackExchange.Redis/Format.cs b/src/StackExchange.Redis/Format.cs index 70d8003aa..a57bea0cd 100644 --- a/src/StackExchange.Redis/Format.cs +++ b/src/StackExchange.Redis/Format.cs @@ -131,6 +131,28 @@ internal static string ToStringHostOnly(EndPoint endpoint) => _ => "", }; + /// + /// Gets the TLS SNI and certificate-validation host for a connection to . + /// + /// + /// An explicitly configured wins. Otherwise, a + /// uses its own host and any other endpoint uses its address. + /// + internal static string GetTlsHostName(EndPoint endpoint, string? sslHost) + { + if (!sslHost.IsNullOrWhiteSpace()) + { + return sslHost; + } + + if (endpoint is DnsEndPoint dns && !dns.Host.IsNullOrWhiteSpace()) + { + return dns.Host; + } + + return ToStringHostOnly(endpoint); + } + internal static bool TryGetHostPort(EndPoint? endpoint, [NotNullWhen(true)] out string? host, [NotNullWhen(true)] out int? port) { if (endpoint is not null) diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 6eb70a100..cbedd4225 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -1226,11 +1226,7 @@ static Stream DemandSocketStream(Socket? socket) if (config.Ssl) { log?.LogInformationConfiguringTLS(); - var host = config.SslHost; - if (host.IsNullOrWhiteSpace()) - { - host = Format.ToStringHostOnly(bridge.ServerEndPoint.EndPoint); - } + var host = config.ResolveTlsHostName(bridge.ServerEndPoint.EndPoint); stream ??= DemandSocketStream(socket); var ssl = new SslStream( diff --git a/tests/StackExchange.Redis.Tests/SSLTests.cs b/tests/StackExchange.Redis.Tests/SSLTests.cs index 731701755..2230af9c6 100644 --- a/tests/StackExchange.Redis.Tests/SSLTests.cs +++ b/tests/StackExchange.Redis.Tests/SSLTests.cs @@ -364,7 +364,7 @@ public async Task RedisLabsEnvironmentVariableClientCertificate(bool setEnv) } [Fact] - public void SSLHostInferredFromEndpoints() + public void SSLHostIsOnlyExplicitlyConfigured() { var options = new ConfigurationOptions { @@ -376,7 +376,9 @@ public void SSLHostInferredFromEndpoints() }, Ssl = true, }; - Assert.Equal("mycache.rediscache.windows.net", options.SslHost); + Assert.Null(options.SslHost); + options.SslHost = "override.rediscache.windows.net"; + Assert.Equal("override.rediscache.windows.net", options.SslHost); options = new ConfigurationOptions() { EndPoints = { { "121.23.23.45", 15000 } }, diff --git a/tests/StackExchange.Redis.Tests/TlsHostNameUnitTests.cs b/tests/StackExchange.Redis.Tests/TlsHostNameUnitTests.cs new file mode 100644 index 000000000..e2ac6742a --- /dev/null +++ b/tests/StackExchange.Redis.Tests/TlsHostNameUnitTests.cs @@ -0,0 +1,88 @@ +using System.Net; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// TLS target-host selection for SNI and certificate validation. +/// +public class TlsHostNameUnitTests +{ + [Fact] + public void ExplicitSslHostOverridesDnsEndPoint() + { + var endpoint = new DnsEndPoint("host-2.redis.example.com", 443); + var options = new ConfigurationOptions { SslHost = "host-1.redis.example.com" }; + Assert.Equal("host-1.redis.example.com", options.ResolveTlsHostName(endpoint)); + } + + [Fact] + public void DnsEndPointUsesItsOwnHostInsteadOfInferredHost() + { + var options = new ConfigurationOptions + { + EndPoints = { "host-1.redis.example.com:443" }, + }; + + Assert.Null(options.SslHost); + Assert.Equal("host-2.redis.example.com", options.ResolveTlsHostName(new DnsEndPoint("host-2.redis.example.com", 443))); + } + + [Fact] + public void IpEndPointPrefersExplicitSslHost() + { + var endpoint = new IPEndPoint(IPAddress.Parse("10.0.0.1"), 6379); + var options = new ConfigurationOptions + { + EndPoints = { "host-1.redis.example.com:443" }, + SslHost = "mycache.redis.example.com", + }; + Assert.Equal("mycache.redis.example.com", options.ResolveTlsHostName(endpoint)); + } + + [Fact] + public void IpEndPointUsesInferredHost() + { + var options = new ConfigurationOptions + { + EndPoints = { "host-1.redis.example.com:443" }, + }; + Assert.Equal("host-1.redis.example.com", options.ResolveTlsHostName(new IPEndPoint(IPAddress.Parse("10.0.0.1"), 6379))); + } + + [Fact] + public void IpEndPointFallsBackToAddressWhenHostsAreMissing() + { + var endpoint = new IPEndPoint(IPAddress.Parse("10.0.0.1"), 6379); + Assert.Equal("10.0.0.1", new ConfigurationOptions().ResolveTlsHostName(endpoint)); + } + + [Fact] + public void TlsOptionsResolveHostHonorsExplicitOverride() + { + var options = new ConfigurationOptions + { + EndPoints = { "host-1.redis.example.com:443" }, + Ssl = true, + SslHost = "override.redis.example.com", + }; + var tls = new Configuration.TlsOptions(options); + + Assert.Equal("override.redis.example.com", tls.ResolveHost(new DnsEndPoint("host-2.redis.example.com", 443))); + Assert.Equal("override.redis.example.com", tls.ResolveHost(new IPEndPoint(IPAddress.Parse("10.0.0.1"), 443))); + } + + [Fact] + public void TlsOptionsResolveHostUsesEndpointBeforeInferredHost() + { + var options = new ConfigurationOptions + { + EndPoints = { "host-1.redis.example.com:443" }, + Ssl = true, + }; + var tls = new Configuration.TlsOptions(options); + + Assert.Equal("host-2.redis.example.com", tls.ResolveHost(new DnsEndPoint("host-2.redis.example.com", 443))); + Assert.Equal("host-1.redis.example.com", tls.ResolveHost(new IPEndPoint(IPAddress.Parse("10.0.0.1"), 443))); + } +}