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)));
+ }
+}