fix(cluster): verify TLS against each node host (#4145)

* fix(cluster): verify TLS against each node host

Signed-off-by: Pragalva Sapkota <sapkotapragalva@gmail.com>

* test(cluster): update TLS URL expectations

Signed-off-by: Pragalva Sapkota <sapkotapragalva@gmail.com>

* test(cluster): fix skip-verify TLS expectation

* fix(cluster): make per-node TLS verification opt-in

Keep ServerName pinned to the first URL host by default, so clusters
that share one certificate and announce nodes by IP keep working.
Add tls_verify_node_host=true to clear ServerName and verify each
node against the host it is dialed at.

Refs #4129

Signed-off-by: Pragalva Sapkota <sapkotapragalva@gmail.com>

---------

Signed-off-by: Pragalva Sapkota <sapkotapragalva@gmail.com>
This commit is contained in:
Pragalva authored and GitHub committed 2026-10-21 14:47:08 +03:00
1 parent 856a2f0acd
commit 3ce2862bd3
5 files changed
+263 -6

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
## Unreleased
- **URL configuration validation:** `ParseURL`, `ParseClusterURL`, and `ParseFailoverURL` now reject negative `dialer_retry_timeout`, `read_buffer_size`, and `write_buffer_size` values. Cluster and failover URLs that previously accepted negative retry timeouts now return an error; replace those values with `0` for no delay, or omit the parameter for the default 100 ms delay. An explicit `dialer_retry_timeout=0` now disables the retry delay ([#4144](https://github.com/redis/go-redis/pull/4144)).
- **Per-node TLS verification for cluster URLs:** `ParseClusterURL` accepts `tls_verify_node_host=true` on `rediss://` URLs. The parameter clears `TLSConfig.ServerName`, so each node is verified against the host it is dialed at and not against the host of the first URL. Use it when each node has a certificate for its own host. The default is unchanged: every node is verified against the host of the first URL, so clusters that use one certificate and announce their nodes by IP continue to work. `rediss://` cluster URLs now also set `MinVersion: tls.VersionTLS12`, as `ParseURL` and `ParseFailoverURL` do ([#4145](https://github.com/redis/go-redis/pull/4145)).
# 9.23.0 (2026-10-05)
+11 -1
View File
@@ -348,6 +348,10 @@ func (opt *ClusterOptions) init() {
// or
// rediss://<user>:<password>@<host>:<port>
//
// For rediss URLs, TLS 1.2 is the minimum version and every node is verified
// against the host of the first URL. To verify each node against the host it
// is dialed at, set tls_verify_node_host=true or set TLSConfig.ServerName to "".
//
// To add additional addresses, specify the query parameter, "addr" one or more times. e.g:
//
// redis://<user>:<password>@<host>:<port>?addr=<host2>:<port2>&addr=<host3>:<port3>
@@ -369,6 +373,7 @@ func (opt *ClusterOptions) init() {
// names will be treated as unknown parameters
// - unknown parameter names will result in an error
// - use "skip_verify=true" to ignore TLS certificate validation
// - use "tls_verify_node_host=true" to verify each node against the host it is dialed at
//
// Example:
//
@@ -405,7 +410,7 @@ func ParseClusterURL(redisURL string) (*ClusterOptions, error) {
func setupClusterConn(u *url.URL, host string, o *ClusterOptions) (*ClusterOptions, error) {
switch u.Scheme {
case "rediss":
o.TLSConfig = &tls.Config{ServerName: host}
o.TLSConfig = &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
fallthrough
case "redis":
o.Username, o.Password = getUserPassword(u)
@@ -468,6 +473,11 @@ func setupClusterQueryParams(u *url.URL, o *ClusterOptions) (*ClusterOptions, er
if o.TLSConfig != nil && q.has("skip_verify") {
o.TLSConfig.InsecureSkipVerify = q.bool("skip_verify")
}
// An empty ServerName makes the TLS dialer verify each node against the
// host it is dialed at, instead of the host of the first URL.
if o.TLSConfig != nil && q.has("tls_verify_node_host") && q.bool("tls_verify_node_host") {
o.TLSConfig.ServerName = ""
}
if q.err != nil {
return nil, q.err
+21 -5
View File
@@ -3510,7 +3510,7 @@ var _ = Describe("Command Tips tests", func() {
}, {
test: "ParseRedissURL",
url: "rediss://localhost:123",
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}, TLSConfig: &tls.Config{ServerName: "localhost"}},
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}, TLSConfig: &tls.Config{ServerName: "localhost", MinVersion: tls.VersionTLS12}},
}, {
test: "MissingRedisPort",
url: "redis://localhost",
@@ -3518,7 +3518,7 @@ var _ = Describe("Command Tips tests", func() {
}, {
test: "MissingRedissPort",
url: "rediss://localhost",
o: &redis.ClusterOptions{Addrs: []string{"localhost:6379"}, TLSConfig: &tls.Config{ServerName: "localhost"}},
o: &redis.ClusterOptions{Addrs: []string{"localhost:6379"}, TLSConfig: &tls.Config{ServerName: "localhost", MinVersion: tls.VersionTLS12}},
}, {
test: "MultipleRedisURLs",
url: "redis://localhost:123?addr=localhost:1234&addr=localhost:12345",
@@ -3526,7 +3526,7 @@ var _ = Describe("Command Tips tests", func() {
}, {
test: "MultipleRedissURLs",
url: "rediss://localhost:123?addr=localhost:1234&addr=localhost:12345",
o: &redis.ClusterOptions{Addrs: []string{"localhost:123", "localhost:1234", "localhost:12345"}, TLSConfig: &tls.Config{ServerName: "localhost"}},
o: &redis.ClusterOptions{Addrs: []string{"localhost:123", "localhost:1234", "localhost:12345"}, TLSConfig: &tls.Config{ServerName: "localhost", MinVersion: tls.VersionTLS12}},
}, {
test: "OnlyPassword",
url: "redis://:bar@localhost:123",
@@ -3542,11 +3542,19 @@ var _ = Describe("Command Tips tests", func() {
}, {
test: "RedissUsernamePassword",
url: "rediss://foo:bar@localhost:123?addr=localhost:1234",
o: &redis.ClusterOptions{Addrs: []string{"localhost:123", "localhost:1234"}, Username: "foo", Password: "bar", TLSConfig: &tls.Config{ServerName: "localhost"}},
o: &redis.ClusterOptions{Addrs: []string{"localhost:123", "localhost:1234"}, Username: "foo", Password: "bar", TLSConfig: &tls.Config{ServerName: "localhost", MinVersion: tls.VersionTLS12}},
}, {
test: "RedissSkipVerify",
url: "rediss://localhost:123?skip_verify=true",
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}, TLSConfig: &tls.Config{ServerName: "localhost", InsecureSkipVerify: true}},
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}, TLSConfig: &tls.Config{ServerName: "localhost", MinVersion: tls.VersionTLS12, InsecureSkipVerify: true}},
}, {
test: "RedissVerifyNodeHost",
url: "rediss://localhost:123?addr=127.0.0.1:1234&tls_verify_node_host=true",
o: &redis.ClusterOptions{Addrs: []string{"localhost:123", "127.0.0.1:1234"}, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}},
}, {
test: "RedissVerifyNodeHostFalse",
url: "rediss://localhost:123?tls_verify_node_host=false",
o: &redis.ClusterOptions{Addrs: []string{"localhost:123"}, TLSConfig: &tls.Config{ServerName: "localhost", MinVersion: tls.VersionTLS12}},
}, {
test: "QueryParameters",
url: "redis://localhost:123?read_timeout=2&pool_fifo=true&addr=localhost:1234",
@@ -3595,6 +3603,14 @@ var _ = Describe("Command Tips tests", func() {
test: "SkipVerifyWithoutTLS",
url: "redis://localhost?skip_verify=true",
err: errors.New("redis: unexpected option: skip_verify"),
}, {
test: "InvalidVerifyNodeHost",
url: "rediss://localhost?tls_verify_node_host=yes",
err: errors.New(`redis: invalid tls_verify_node_host boolean: expected true/false/1/0 or an empty string, got "yes"`),
}, {
test: "VerifyNodeHostWithoutTLS",
url: "redis://localhost?tls_verify_node_host=true",
err: errors.New("redis: unexpected option: tls_verify_node_host"),
}, {
test: "UnknownParam",
url: "redis://localhost?abc=123",
+229
View File
@@ -0,0 +1,229 @@
package redis
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"errors"
"math/big"
"net"
"net/http/httptest"
"testing"
"time"
)
func TestParseClusterURLTLSMinVersion(t *testing.T) {
for _, url := range []string{
"rediss://node-a.example:6379?addr=node-b.example:6379",
"rediss://127.0.0.1:6379",
"rediss://[::1]:6379",
} {
t.Run(url, func(t *testing.T) {
opt, err := ParseClusterURL(url)
if err != nil {
t.Fatal(err)
}
if opt.TLSConfig == nil {
t.Fatal("expected TLS config for rediss URL")
}
if got := opt.TLSConfig.MinVersion; got != tls.VersionTLS12 {
t.Errorf("MinVersion = %d, want %d", got, tls.VersionTLS12)
}
if opt.TLSConfig.InsecureSkipVerify {
t.Error("certificate verification must remain enabled")
}
})
}
opt, err := ParseClusterURL("redis://localhost:6379")
if err != nil {
t.Fatal(err)
}
if opt.TLSConfig != nil {
t.Error("redis URL must not enable TLS")
}
}
func TestParseClusterURLTLSNodeVerification(t *testing.T) {
seed := newClusterTLSTestServer(t, "localhost")
next := newClusterTLSTestServer(t, "127.0.0.1")
roots := x509.NewCertPool()
roots.AddCert(seed.Certificate())
roots.AddCert(next.Certificate())
_, port, err := net.SplitHostPort(seed.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
url := "rediss://" + net.JoinHostPort("localhost", port) + "?addr=" + next.Listener.Addr().String()
for _, tc := range []struct {
name string
query string
serverName string
untrusted bool
}{
{name: "per node host", query: "&tls_verify_node_host=true"},
{name: "explicit server name", serverName: "cluster.example"},
{name: "wrong server name", serverName: "wrong.example"},
{name: "untrusted certificate", query: "&tls_verify_node_host=true", untrusted: true},
} {
t.Run(tc.name, func(t *testing.T) {
opt, err := ParseClusterURL(url + tc.query)
if err != nil {
t.Fatal(err)
}
opt.TLSConfig.RootCAs = roots
if tc.untrusted {
opt.TLSConfig.RootCAs = x509.NewCertPool()
}
if tc.serverName != "" {
opt.TLSConfig.ServerName = tc.serverName
}
originalServerName := opt.TLSConfig.ServerName
opt.init()
for _, addr := range opt.Addrs {
node := newClusterNodeWithNodeAddress(opt, addr, addr)
defer node.Close()
conn, err := node.Client.Options().Dialer(context.Background(), "tcp", addr)
if err == nil {
conn.Close()
}
switch {
case tc.serverName == "wrong.example":
var hostErr x509.HostnameError
if !errors.As(err, &hostErr) {
t.Errorf("dial %s: got %v, want hostname verification error", addr, err)
}
case tc.untrusted:
var authorityErr x509.UnknownAuthorityError
if !errors.As(err, &authorityErr) {
t.Errorf("dial %s: got %v, want untrusted certificate error", addr, err)
}
default:
if err != nil {
t.Errorf("dial %s: %v", addr, err)
}
}
}
if opt.TLSConfig.ServerName != originalServerName {
t.Error("dialing a node mutated the shared TLS config")
}
})
}
}
// Many clusters use one certificate for the cluster DNS name and announce
// their nodes by IP. By default every node is verified against the host of the
// first URL, so a node dialed by IP must still accept that certificate.
func TestParseClusterURLTLSSharedCertificate(t *testing.T) {
certificate, leaf := newClusterTLSTestCertificate(t, "localhost")
seed := newClusterTLSTestServerWithCertificate(t, certificate)
next := newClusterTLSTestServerWithCertificate(t, certificate)
roots := x509.NewCertPool()
roots.AddCert(leaf)
_, port, err := net.SplitHostPort(seed.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
nextAddr := next.Listener.Addr().String()
url := "rediss://" + net.JoinHostPort("localhost", port) + "?addr=" + nextAddr
for _, tc := range []struct {
name string
query string
nextHostError bool
}{
{name: "default"},
{name: "per node host", query: "&tls_verify_node_host=true", nextHostError: true},
} {
t.Run(tc.name, func(t *testing.T) {
opt, err := ParseClusterURL(url + tc.query)
if err != nil {
t.Fatal(err)
}
opt.TLSConfig.RootCAs = roots
opt.init()
for _, addr := range opt.Addrs {
node := newClusterNodeWithNodeAddress(opt, addr, addr)
defer node.Close()
conn, err := node.Client.Options().Dialer(context.Background(), "tcp", addr)
if err == nil {
conn.Close()
}
if tc.nextHostError && addr == nextAddr {
var hostErr x509.HostnameError
if !errors.As(err, &hostErr) {
t.Errorf("dial %s: got %v, want hostname verification error", addr, err)
}
continue
}
if err != nil {
t.Errorf("dial %s: %v", addr, err)
}
}
})
}
}
// Each node certificate covers its own host and a shared DNS name, but not the
// other node's host. No external Redis server is needed.
func newClusterTLSTestServer(t *testing.T, host string) *httptest.Server {
t.Helper()
certificate, _ := newClusterTLSTestCertificate(t, "cluster.example", host)
return newClusterTLSTestServerWithCertificate(t, certificate)
}
// newClusterTLSTestCertificate returns a self-signed certificate that is valid
// only for the given hosts. An IP host becomes an IP SAN, other hosts DNS SANs.
func newClusterTLSTestCertificate(t *testing.T, hosts ...string) (tls.Certificate, *x509.Certificate) {
t.Helper()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
for _, host := range hosts {
if ip := net.ParseIP(host); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, host)
}
}
der, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey)
if err != nil {
t.Fatal(err)
}
leaf, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: privateKey, Leaf: leaf}, leaf
}
func newClusterTLSTestServerWithCertificate(t *testing.T, certificate tls.Certificate) *httptest.Server {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
server := httptest.NewUnstartedServer(nil)
server.Listener.Close()
server.Listener = listener
server.TLS = &tls.Config{
Certificates: []tls.Certificate{certificate},
MinVersion: tls.VersionTLS12,
}
server.StartTLS()
t.Cleanup(server.Close)
return server
}
+1
View File
@@ -277,6 +277,7 @@ var _ = Describe("TLS Cluster", Label("NonRedisEnterprise"), func() {
Expect(opt.Addrs).To(ContainElements("localhost:16800", "localhost:16801", "localhost:16802"))
Expect(opt.TLSConfig).NotTo(BeNil())
Expect(opt.TLSConfig.ServerName).To(Equal("localhost"))
Expect(opt.TLSConfig.MinVersion).To(Equal(uint16(tls.VersionTLS12)))
})
})
})