mirror of
https://github.com/redis/go-redis.git
synced 2026-10-28 08:24:54 +08:00
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:
1 parent
856a2f0acd
commit
3ce2862bd3
5 files changed
+263
-6
No files matched your search
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)))
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user