security: fail closed on gRPC TLS errors, verify dialed server identity (#12263)

* security: fail closed on misconfigured gRPC TLS

LoadServerTLS/LoadClientTLS returned plaintext credentials on every
error path once TLS was configured: a typo in tls.min_version or
tls.cipher_suites, cert and key in different directories, or cert/key
set without grpc.ca all left the component serving or dialing
plaintext. A partially configured TLS setup now stops the process at
startup instead; leaving cert/key unset still means no TLS, as before.

The loaders also stat cert/key/ca files up front since the pemfile
providers read lazily and would otherwise only fail at handshake time.

Mirrored in the Rust volume server: partial grpc.volume cert/key, a
missing grpc.ca, or a rustls build failure now abort startup rather
than warn-and-continue on plaintext.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* security: optional server-identity allowlist for gRPC clients

The mTLS client accepts any certificate the configured CA signs, so a
per-workload CA lets one component stand in for any other: a filer
identity can answer as the master. The server side already screens
callers via allowed_commonNames and allowed_wildcard_domain; nothing
screened the server.

LoadClientTLS now wires AdditionalPeerVerification when the caller
configures a server allowlist: grpc.<component>.allowed_server_commonNames
(falling back to a shared grpc.allowed_server_commonNames) matched
against the peer cert's subject CN, DNS SANs, or URI SANs such as a
SPIFFE id, plus a grpc.allowed_server_wildcard_domain suffix match.
Leaving them unset keeps the previous behavior.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* security: URI SANs are exact-match only in the server identity allowlist

A URI identity whose path happens to end in the wildcard domain would
otherwise pass the DNS suffix check.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* security: tighten gRPC TLS fail-closed and server-identity paths

- URI SANs match the server-identity allowlist exactly; the wildcard
  domain suffix check applies only to CN and DNS SANs.
- A partially set client_cert/client_key pair is an error when the
  cert/key fallback is empty, instead of dialing plaintext.
- Cert/key/ca files are loaded and parsed at startup (LoadX509KeyPair
  and CA PEM validation) since pemfile providers read lazily.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* rust volume: error on a partial grpc client_cert/client_key pair

Mirror the Go loadClientTLS path: when client_cert or client_key is
set without its mate and the cert/key fallback is empty, fail instead
of dialing plaintext.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* security: wildcard domain match requires a valid hostname

A CA-signed identity like ".example.com" satisfied the bare suffix check
for wildcard ".example.com" despite not being a hostname. Require a real
subdomain: non-empty label before the suffix, a '.' boundary when the
configured wildcard has no leading dot, and DNS-name syntax. A leading
"*." label still matches so wildcard certs keep working. Applied to both
the Go server/client authenticators and the Rust volume's client-cert
verifier.

* security: ignore a trailing root dot in wildcard domain matching

* tls: reject a partial dedicated client cert/key pair instead of falling back

When exactly one of component.client_cert/client_key is set, warn-and-
fallback silently dialed with the serving identity. Fail closed like the
other TLS misconfigurations in this change; mirrored in the Rust loader.
Regression tests cover a partial pair with a valid fallback.

* rust volume: harden partial client cert pair test with real pem files

* security: verify dialed server identity per target kind on the filer

The filer reused one grpc.filer-verifying dial option for master, volume,
filer and s3 targets, so a broad allowlist accepted the wrong component at
the wrong port and a narrow one rejected legitimate peers. Outbound calls
now pick grpc.<target>.allowed_server_commonNames (falling back to the
client component's and the global list) while still presenting the
grpc.filer client identity.

* security: scope per-target server allowlists under the client component

The filer read grpc.volume.allowed_server_commonNames when dialing a
volume, but that key already denotes the volume's own outbound allowlist
(e.g. the master identity). A deployment restricting volume->master
calls would make the filer reject real volume servers. Per-target policy
now lives under the client component: grpc.filer.volume_*
falls back to grpc.filer.* then grpc.*.

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Chris LuandDevin authored and GitHub committed 2026-10-30 20:04:45 -07:00
1 parent 7d8b72c2b4
commit e98adfd3bb
15 files changed
+821 -101

No files matched your search

+201 -44
View File
@@ -67,8 +67,16 @@ func (s *SNIStrippingTransportCredentials) OverrideServerName(serverNameOverride
}
func LoadServerTLS(config *util.ViperProxy, component string) (grpc.ServerOption, grpc.ServerOption) {
creds, verify, err := loadServerTLS(config, component)
if err != nil {
glog.Fatalf("gRPC server TLS for %s: %v", component, err)
}
return creds, verify
}
func loadServerTLS(config *util.ViperProxy, component string) (grpc.ServerOption, grpc.ServerOption, error) {
if config == nil {
return nil, nil
return nil, nil, nil
}
serverOptions := pemfile.Options{
@@ -76,14 +84,19 @@ func LoadServerTLS(config *util.ViperProxy, component string) (grpc.ServerOption
KeyFile: config.GetString(component + ".key"),
RefreshDuration: CredRefreshingInterval,
}
if serverOptions.CertFile == "" && serverOptions.KeyFile == "" {
return nil, nil, nil
}
if serverOptions.CertFile == "" || serverOptions.KeyFile == "" {
return nil, nil
return nil, nil, fmt.Errorf("%s.cert and %s.key must both be set", component, component)
}
if err := checkTLSFiles(serverOptions.CertFile, serverOptions.KeyFile, config.GetString("grpc.ca")); err != nil {
return nil, nil, err
}
serverIdentityProvider, err := pemfile.NewProvider(serverOptions)
if err != nil {
glog.Warningf("pemfile.NewProvider(%v) %v failed: %v", serverOptions, component, err)
return nil, nil
return nil, nil, fmt.Errorf("pemfile.NewProvider(%v) failed: %w", serverOptions, err)
}
serverRootOptions := pemfile.Options{
@@ -92,8 +105,7 @@ func LoadServerTLS(config *util.ViperProxy, component string) (grpc.ServerOption
}
serverRootProvider, err := pemfile.NewProvider(serverRootOptions)
if err != nil {
glog.Warningf("pemfile.NewProvider(%v) failed: %v", serverRootOptions, err)
return nil, nil
return nil, nil, fmt.Errorf("pemfile.NewProvider(%v) failed: %w", serverRootOptions, err)
}
// Start a server and create a client using advancedtls API with Provider.
@@ -109,30 +121,21 @@ func LoadServerTLS(config *util.ViperProxy, component string) (grpc.ServerOption
}
options.MinTLSVersion, err = TlsVersionByName(config.GetString("tls.min_version"))
if err != nil {
glog.Warningf("tls min version parse failed, %v", err)
return nil, nil
return nil, nil, err
}
options.MaxTLSVersion, err = TlsVersionByName(config.GetString("tls.max_version"))
if err != nil {
glog.Warningf("tls max version parse failed, %v", err)
return nil, nil
return nil, nil, err
}
options.CipherSuites, err = TlsCipherSuiteByNames(config.GetString("tls.cipher_suites"))
if err != nil {
glog.Warningf("tls cipher suite parse failed, %v", err)
return nil, nil
return nil, nil, err
}
allowedCommonNames := config.GetString(component + ".allowed_commonNames")
allowedWildcardDomain := config.GetString("grpc.allowed_wildcard_domain")
if allowedCommonNames != "" || allowedWildcardDomain != "" {
allowedCommonNamesMap := make(map[string]bool)
for _, s := range strings.Split(allowedCommonNames, ",") {
allowedCommonNamesMap[s] = true
}
auther := Authenticator{
AllowedCommonNames: allowedCommonNamesMap,
AllowedWildcardDomain: allowedWildcardDomain,
}
auther := Authenticator{
AllowedCommonNames: parseAllowedCommonNames(config.GetString(component + ".allowed_commonNames")),
AllowedWildcardDomain: config.GetString("grpc.allowed_wildcard_domain"),
}
if len(auther.AllowedCommonNames) != 0 || auther.AllowedWildcardDomain != "" {
options.AdditionalPeerVerification = auther.Authenticate
} else {
options.AdditionalPeerVerification = func(params *advancedtls.HandshakeVerificationInfo) (*advancedtls.PostHandshakeVerificationResults, error) {
@@ -141,10 +144,9 @@ func LoadServerTLS(config *util.ViperProxy, component string) (grpc.ServerOption
}
ta, err := advancedtls.NewServerCreds(options)
if err != nil {
glog.Warningf("advancedtls.NewServerCreds(%v) failed: %v", options, err)
return nil, nil
return nil, nil, fmt.Errorf("advancedtls.NewServerCreds failed: %w", err)
}
return grpc.Creds(ta), nil
return grpc.Creds(ta), nil, nil
}
func LoadClientTLSFromFile(configFile string, component string) (grpc.DialOption, error) {
@@ -165,21 +167,56 @@ func LoadClientTLSFromFile(configFile string, component string) (grpc.DialOption
}
func LoadClientTLS(config *util.ViperProxy, component string) grpc.DialOption {
option, err := loadClientTLS(config, component, component)
if err != nil {
glog.Fatalf("gRPC client TLS for %s: %v", component, err)
}
return option
}
// LoadClientTLSForTarget builds the component's client dial option while
// selecting the server-identity allowlist for a specific target kind: a filer
// dialing a volume keeps presenting its grpc.filer client certificate but
// verifies the peer against grpc.filer.volume_allowed_server_commonNames. The
// server-name and wildcard keys fall back from the client's per-target key to
// the component key to grpc.*, so a deployment without per-target keys
// verifies exactly as before. The per-target key lives under the client
// component because grpc.<component>.allowed_server_commonNames denotes that
// component's own outbound allowlist.
func LoadClientTLSForTarget(config *util.ViperProxy, clientComponent, targetComponent string) grpc.DialOption {
option, err := loadClientTLS(config, clientComponent, targetComponent)
if err != nil {
glog.Fatalf("gRPC client TLS for %s: %v", clientComponent, err)
}
return option
}
func loadClientTLS(config *util.ViperProxy, component, targetComponent string) (grpc.DialOption, error) {
if config == nil {
return grpc.WithTransportCredentials(insecure.NewCredentials())
return grpc.WithTransportCredentials(insecure.NewCredentials()), nil
}
// prefer a dedicated client certificate: CAs may issue certs with only one of the serverAuth/clientAuth EKUs
certFileName, keyFileName := config.GetString(component+".client_cert"), config.GetString(component+".client_key")
partialClientPair := (certFileName == "") != (keyFileName == "")
if partialClientPair {
return nil, fmt.Errorf("%s.client_cert and %s.client_key must both be set", component, component)
}
if certFileName == "" || keyFileName == "" {
if certFileName != "" || keyFileName != "" {
glog.Warningf("%s.client_cert and %s.client_key must both be set, falling back to %s.cert and %s.key", component, component, component, component)
}
certFileName, keyFileName = config.GetString(component+".cert"), config.GetString(component+".key")
}
if certFileName == "" && keyFileName == "" {
return grpc.WithTransportCredentials(insecure.NewCredentials()), nil
}
if certFileName == "" || keyFileName == "" {
return nil, fmt.Errorf("%s.cert and %s.key must both be set", component, component)
}
caFileName := config.GetString("grpc.ca")
if certFileName == "" || keyFileName == "" || caFileName == "" {
return grpc.WithTransportCredentials(insecure.NewCredentials())
if caFileName == "" {
return nil, fmt.Errorf("%s cert and key are set but grpc.ca is not", component)
}
if err := checkTLSFiles(certFileName, keyFileName, caFileName); err != nil {
return nil, err
}
clientOptions := pemfile.Options{
@@ -189,37 +226,54 @@ func LoadClientTLS(config *util.ViperProxy, component string) grpc.DialOption {
}
clientProvider, err := pemfile.NewProvider(clientOptions)
if err != nil {
glog.Warningf("pemfile.NewProvider(%v) failed %v", clientOptions, err)
return grpc.WithTransportCredentials(insecure.NewCredentials())
return nil, fmt.Errorf("pemfile.NewProvider(%v) failed: %w", clientOptions, err)
}
clientRootOptions := pemfile.Options{
RootFile: config.GetString("grpc.ca"),
RootFile: caFileName,
RefreshDuration: CredRefreshingInterval,
}
clientRootProvider, err := pemfile.NewProvider(clientRootOptions)
if err != nil {
glog.Warningf("pemfile.NewProvider(%v) failed: %v", clientRootOptions, err)
return grpc.WithTransportCredentials(insecure.NewCredentials())
return nil, fmt.Errorf("pemfile.NewProvider(%v) failed: %w", clientRootOptions, err)
}
options := &advancedtls.Options{
IdentityOptions: advancedtls.IdentityCertificateOptions{
IdentityProvider: clientProvider,
},
AdditionalPeerVerification: func(params *advancedtls.HandshakeVerificationInfo) (*advancedtls.PostHandshakeVerificationResults, error) {
return &advancedtls.PostHandshakeVerificationResults{}, nil
},
RootOptions: advancedtls.RootCertificateOptions{
RootProvider: clientRootProvider,
},
VerificationType: advancedtls.CertVerification,
}
targetPrefix := ""
if targetComponent != component {
targetPrefix = strings.TrimPrefix(targetComponent, "grpc.") + "_"
}
allowedServerNames := firstConfigString(config,
component+"."+targetPrefix+"allowed_server_commonNames",
component+".allowed_server_commonNames",
"grpc.allowed_server_commonNames")
allowedWildcardDomain := firstConfigString(config,
component+"."+targetPrefix+"allowed_server_wildcard_domain",
component+".allowed_server_wildcard_domain",
"grpc.allowed_server_wildcard_domain")
auther := Authenticator{
AllowedCommonNames: parseAllowedCommonNames(allowedServerNames),
AllowedWildcardDomain: allowedWildcardDomain,
}
if len(auther.AllowedCommonNames) != 0 || auther.AllowedWildcardDomain != "" {
options.AdditionalPeerVerification = auther.AuthenticateServer
} else {
options.AdditionalPeerVerification = func(params *advancedtls.HandshakeVerificationInfo) (*advancedtls.PostHandshakeVerificationResults, error) {
return &advancedtls.PostHandshakeVerificationResults{}, nil
}
}
ta, err := advancedtls.NewClientCreds(options)
if err != nil {
glog.Warningf("advancedtls.NewClientCreds(%v) failed: %v", options, err)
return grpc.WithTransportCredentials(insecure.NewCredentials())
return nil, fmt.Errorf("advancedtls.NewClientCreds failed: %w", err)
}
wrapped := &SNIStrippingTransportCredentials{creds: ta}
return grpc.WithTransportCredentials(wrapped)
return grpc.WithTransportCredentials(wrapped), nil
}
// LoadHTTPClientFromFile creates an HTTP client using the https.client TLS
@@ -272,8 +326,88 @@ func LoadClientTLSHTTP(clientCertFile string) *tls.Config {
}
}
// checkTLSFiles validates configured TLS material up front: the pemfile
// providers read lazily, so without this a typo'd or unparseable file would
// only surface at handshake time instead of at startup.
func checkTLSFiles(certFile, keyFile, caFile string) error {
if _, err := tls.LoadX509KeyPair(certFile, keyFile); err != nil {
return err
}
if caFile == "" {
return nil
}
caPem, err := os.ReadFile(caFile)
if err != nil {
return err
}
if !x509.NewCertPool().AppendCertsFromPEM(caPem) {
return fmt.Errorf("no CA certificates found in %s", caFile)
}
return nil
}
func parseAllowedCommonNames(commonNames string) map[string]bool {
allowed := make(map[string]bool)
for _, s := range strings.Split(commonNames, ",") {
if s = strings.TrimSpace(s); s != "" {
allowed[s] = true
}
}
return allowed
}
func firstConfigString(config *util.ViperProxy, keys ...string) string {
for _, key := range keys {
if v := config.GetString(key); v != "" {
return v
}
}
return ""
}
// matchesWildcardDomain requires a real subdomain of the wildcard: the name
// must end with the domain, carry a non-empty label in front of it, and be a
// valid DNS name, so a CA-signed name like ".example.com" cannot satisfy the
// suffix check. A leading "*." label is allowed so wildcard certs keep
// matching. One trailing root dot is ignored on both sides so absolute DNS
// names still match.
func matchesWildcardDomain(name, wildcardDomain string) bool {
name = strings.TrimSuffix(name, ".")
wildcardDomain = strings.TrimSuffix(wildcardDomain, ".")
if wildcardDomain == "" || len(name) <= len(wildcardDomain) || !strings.HasSuffix(name, wildcardDomain) {
return false
}
if wildcardDomain[0] != '.' && name[len(name)-len(wildcardDomain)-1] != '.' {
return false
}
return isDomainName(strings.TrimPrefix(name, "*."))
}
// isDomainName mirrors the Rust volume's check: non-empty labels of letters,
// digits, '-', '_', each up to 63 chars and not '-'-edged, with at least one
// non-digit so a bare IP cannot pass a domain wildcard.
func isDomainName(name string) bool {
if len(name) == 0 || len(name) > 253 {
return false
}
nonNumeric := false
for _, label := range strings.Split(name, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
if c != '-' && c != '_' && !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') {
return false
}
nonNumeric = nonNumeric || c < '0' || c > '9'
}
}
return nonNumeric
}
func (a Authenticator) Authenticate(params *advancedtls.HandshakeVerificationInfo) (*advancedtls.PostHandshakeVerificationResults, error) {
if a.AllowedWildcardDomain != "" && strings.HasSuffix(params.Leaf.Subject.CommonName, a.AllowedWildcardDomain) {
if matchesWildcardDomain(params.Leaf.Subject.CommonName, a.AllowedWildcardDomain) {
return &advancedtls.PostHandshakeVerificationResults{}, nil
}
if _, ok := a.AllowedCommonNames[params.Leaf.Subject.CommonName]; ok {
@@ -284,6 +418,29 @@ func (a Authenticator) Authenticate(params *advancedtls.HandshakeVerificationInf
return nil, err
}
func (a Authenticator) AuthenticateServer(params *advancedtls.HandshakeVerificationInfo) (*advancedtls.PostHandshakeVerificationResults, error) {
leaf := params.Leaf
names := make([]string, 0, len(leaf.DNSNames)+len(leaf.URIs)+1)
names = append(names, leaf.Subject.CommonName)
names = append(names, leaf.DNSNames...)
for _, name := range names {
if a.AllowedCommonNames[name] || matchesWildcardDomain(name, a.AllowedWildcardDomain) {
return &advancedtls.PostHandshakeVerificationResults{}, nil
}
}
for _, u := range leaf.URIs {
// URI identities match exactly: a path that happens to end in the
// wildcard domain must not pass the DNS suffix check.
if a.AllowedCommonNames[u.String()] {
return &advancedtls.PostHandshakeVerificationResults{}, nil
}
names = append(names, u.String())
}
err := fmt.Errorf("AuthenticateServer: untrusted server identity %v", names)
glog.Error(err)
return nil, err
}
func FixTlsConfig(viper *util.ViperProxy, config *tls.Config) error {
var err error
config.MinVersion, err = TlsVersionByName(viper.GetString("tls.min_version"))
+351 -1
View File
@@ -10,6 +10,7 @@ import (
"encoding/pem"
"math/big"
"net"
"net/url"
"os"
"path/filepath"
"testing"
@@ -59,6 +60,10 @@ func newTestCA(t *testing.T, dir string) *testCA {
// issue creates a leaf certificate restricted to the given extended key usages.
func (ca *testCA) issue(t *testing.T, dir, name string, ekus []x509.ExtKeyUsage) (certFile, keyFile string) {
return ca.issueWithSANs(t, dir, name, ekus, []string{"localhost"}, nil)
}
func (ca *testCA) issueWithSANs(t *testing.T, dir, name string, ekus []x509.ExtKeyUsage, dnsNames []string, uris []string) (certFile, keyFile string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
@@ -72,7 +77,14 @@ func (ca *testCA) issue(t *testing.T, dir, name string, ekus []x509.ExtKeyUsage)
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: ekus,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"localhost"},
DNSNames: dnsNames,
}
for _, u := range uris {
parsed, err := url.Parse(u)
if err != nil {
t.Fatal(err)
}
template.URIs = append(template.URIs, parsed)
}
der, err := x509.CreateCertificate(rand.Reader, template, ca.cert, &key.PublicKey, ca.key)
if err != nil {
@@ -181,3 +193,341 @@ func TestLoadClientTLSServerOnlyEkuRejected(t *testing.T) {
t.Fatal("expected handshake failure when presenting a serverAuth-only cert as client identity")
}
}
func TestLoadServerTLSUnconfiguredIsPlaintext(t *testing.T) {
v := util.NewViperProxy(viper.New())
creds, _, err := loadServerTLS(v, "grpc.master")
if err != nil || creds != nil {
t.Fatalf("expected no TLS and no error, got creds=%v err=%v", creds, err)
}
}
func TestLoadServerTLSPartialCertKeyFails(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
certFile, _ := ca.issue(t, dir, "server", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", ca.file)
v.Set("grpc.master.cert", certFile)
if _, _, err := loadServerTLS(v, "grpc.master"); err == nil {
t.Fatal("expected error when only cert is set")
}
}
func TestLoadServerTLSBadVersionNameFails(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
certFile, keyFile := ca.issue(t, dir, "server", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", ca.file)
v.Set("grpc.master.cert", certFile)
v.Set("grpc.master.key", keyFile)
v.Set("tls.min_version", "bogus")
if _, _, err := loadServerTLS(v, "grpc.master"); err == nil {
t.Fatal("expected error for unparseable tls.min_version")
}
}
func TestLoadClientTLSUnconfiguredIsPlaintext(t *testing.T) {
v := util.NewViperProxy(viper.New())
if _, err := loadClientTLS(v, "grpc.client", "grpc.client"); err != nil {
t.Fatalf("expected insecure dial option, got err=%v", err)
}
}
// A dedicated client pair missing one half is a misconfiguration: the dialer
// must fail rather than silently dial with the fallback serving identity.
func TestLoadClientTLSPartialClientPairFails(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
certFile, keyFile := ca.issue(t, dir, "dual", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
clientCert, _ := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", ca.file)
v.Set("grpc.client.cert", certFile)
v.Set("grpc.client.key", keyFile)
v.Set("grpc.client.client_cert", clientCert)
if _, err := loadClientTLS(v, "grpc.client", "grpc.client"); err == nil {
t.Fatal("expected error for a partial client_cert/client_key pair with a valid fallback")
}
}
func TestLoadClientTLSMissingCaFails(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
certFile, keyFile := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.client.cert", certFile)
v.Set("grpc.client.key", keyFile)
if _, err := loadClientTLS(v, "grpc.client", "grpc.client"); err == nil {
t.Fatal("expected error when cert and key are set without grpc.ca")
}
}
func TestLoadClientTLSBadCaPathFails(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
certFile, keyFile := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", filepath.Join(dir, "missing-ca.pem"))
v.Set("grpc.client.cert", certFile)
v.Set("grpc.client.key", keyFile)
if _, err := loadClientTLS(v, "grpc.client", "grpc.client"); err == nil {
t.Fatal("expected error for unreadable grpc.ca")
}
}
func TestLoadClientTLSAllowsListedServerName(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
serverCert, serverKey := ca.issue(t, dir, "seaweed-filer", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
clientCert, clientKey := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", ca.file)
v.Set("grpc.master.cert", serverCert)
v.Set("grpc.master.key", serverKey)
v.Set("grpc.master.client_cert", clientCert)
v.Set("grpc.master.client_key", clientKey)
v.Set("grpc.master.allowed_server_commonNames", "seaweed-filer,seaweed-volume")
addr := startTestGrpcServer(t, v, "grpc.master")
if err := healthCheck(t, addr, LoadClientTLS(v, "grpc.master")); err != nil {
t.Fatalf("health check with listed server name failed: %v", err)
}
}
// Any CA-signed cert must not be able to stand in for another component:
// a filer's identity dialing as "the master" has to fail the allowlist.
func TestLoadClientTLSRejectsUnlistedServerName(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
serverCert, serverKey := ca.issue(t, dir, "seaweed-filer", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
clientCert, clientKey := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", ca.file)
v.Set("grpc.master.cert", serverCert)
v.Set("grpc.master.key", serverKey)
v.Set("grpc.master.client_cert", clientCert)
v.Set("grpc.master.client_key", clientKey)
v.Set("grpc.master.allowed_server_commonNames", "seaweed-master")
addr := startTestGrpcServer(t, v, "grpc.master")
if err := healthCheck(t, addr, LoadClientTLS(v, "grpc.master")); err == nil {
t.Fatal("expected handshake failure for a server identity outside the allowlist")
}
}
func TestLoadClientTLSMatchesServerURISAN(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
serverCert, serverKey := ca.issueWithSANs(t, dir, "server",
[]x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, nil, []string{"spiffe://cluster.local/ns/seaweedfs/master"})
clientCert, clientKey := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", ca.file)
v.Set("grpc.master.cert", serverCert)
v.Set("grpc.master.key", serverKey)
v.Set("grpc.master.client_cert", clientCert)
v.Set("grpc.master.client_key", clientKey)
v.Set("grpc.master.allowed_server_commonNames", "spiffe://cluster.local/ns/seaweedfs/master")
addr := startTestGrpcServer(t, v, "grpc.master")
if err := healthCheck(t, addr, LoadClientTLS(v, "grpc.master")); err != nil {
t.Fatalf("health check with URI SAN allowlist failed: %v", err)
}
}
func TestLoadClientTLSMatchesServerWildcardDomain(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
serverCert, serverKey := ca.issueWithSANs(t, dir, "server",
[]x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, []string{"master.example.com"}, nil)
clientCert, clientKey := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", ca.file)
v.Set("grpc.master.cert", serverCert)
v.Set("grpc.master.key", serverKey)
v.Set("grpc.master.client_cert", clientCert)
v.Set("grpc.master.client_key", clientKey)
v.Set("grpc.allowed_server_wildcard_domain", ".example.com")
addr := startTestGrpcServer(t, v, "grpc.master")
if err := healthCheck(t, addr, LoadClientTLS(v, "grpc.master")); err != nil {
t.Fatalf("health check with wildcard domain failed: %v", err)
}
}
func TestMatchesWildcardDomain(t *testing.T) {
cases := []struct {
name string
domain string
want bool
}{
{"node.prod.example.com", ".example.com", true},
{"node.example.com.", ".example.com", true},
{"node.example.com.", ".example.com.", true},
{"node.example.com..", ".example.com", false},
{"node..example.com.", ".example.com", false},
{"example.com", ".example.com", false},
{".example.com", ".example.com", false},
{"node..example.com", ".example.com", false},
{"node-.example.com", ".example.com", false},
{"-node.example.com", ".example.com", false},
{"node.example.com", "", false},
{"node.example.com", ".other.net", false},
{"badexample.com", "example.com", false},
{"good.example.com", "example.com", true},
{"*.example.com", ".example.com", true},
{"1.2.3.4", ".3.4", false},
}
for _, c := range cases {
if got := matchesWildcardDomain(c.name, c.domain); got != c.want {
t.Errorf("matchesWildcardDomain(%q, %q) = %v, want %v", c.name, c.domain, got, c.want)
}
}
}
// A CA-signed name that is not a valid hostname must not satisfy the wildcard
// suffix check: ".example.com" equals the wildcard and has no hostname label.
func TestLoadClientTLSRejectsInvalidWildcardName(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
serverCert, serverKey := ca.issueWithSANs(t, dir, ".example.com",
[]x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, []string{".example.com"}, nil)
clientCert, clientKey := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", ca.file)
v.Set("grpc.master.cert", serverCert)
v.Set("grpc.master.key", serverKey)
v.Set("grpc.master.client_cert", clientCert)
v.Set("grpc.master.client_key", clientKey)
v.Set("grpc.allowed_server_wildcard_domain", ".example.com")
addr := startTestGrpcServer(t, v, "grpc.master")
if err := healthCheck(t, addr, LoadClientTLS(v, "grpc.master")); err == nil {
t.Fatal("expected handshake failure: a non-hostname identity must not match the wildcard domain")
}
}
// A URI SAN whose path happens to end in the wildcard domain must not pass:
// URI identities are exact-match only.
func TestLoadClientTLSURIPathDoesNotMatchWildcardDomain(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
serverCert, serverKey := ca.issueWithSANs(t, dir, "server",
[]x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, nil, []string{"spiffe://other.invalid/workload.example.com"})
clientCert, clientKey := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", ca.file)
v.Set("grpc.master.cert", serverCert)
v.Set("grpc.master.key", serverKey)
v.Set("grpc.master.client_cert", clientCert)
v.Set("grpc.master.client_key", clientKey)
v.Set("grpc.allowed_server_wildcard_domain", ".example.com")
addr := startTestGrpcServer(t, v, "grpc.master")
if err := healthCheck(t, addr, LoadClientTLS(v, "grpc.master")); err == nil {
t.Fatal("expected handshake failure: URI SAN path must not match the wildcard domain")
}
}
func TestLoadClientTLSGlobalServerNamesFallback(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
serverCert, serverKey := ca.issue(t, dir, "seaweed-filer", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
clientCert, clientKey := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", ca.file)
v.Set("grpc.master.cert", serverCert)
v.Set("grpc.master.key", serverKey)
v.Set("grpc.master.client_cert", clientCert)
v.Set("grpc.master.client_key", clientKey)
v.Set("grpc.allowed_server_commonNames", "seaweed-filer")
addr := startTestGrpcServer(t, v, "grpc.master")
if err := healthCheck(t, addr, LoadClientTLS(v, "grpc.master")); err != nil {
t.Fatalf("health check with global allowlist failed: %v", err)
}
}
// A per-target allowlist lives under the dialing component: the filer reads
// grpc.filer.volume_allowed_server_commonNames for volume calls, never
// grpc.volume.allowed_server_commonNames, which is the volume's own outbound
// allowlist (e.g. naming only the master).
func TestLoadClientTLSForTargetUsesClientScopedAllowlist(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
serverCert, serverKey := ca.issue(t, dir, "seaweed-volume", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
clientCert, clientKey := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", ca.file)
v.Set("grpc.volume.cert", serverCert)
v.Set("grpc.volume.key", serverKey)
v.Set("grpc.volume.allowed_server_commonNames", "seaweed-master")
v.Set("grpc.filer.client_cert", clientCert)
v.Set("grpc.filer.client_key", clientKey)
v.Set("grpc.filer.volume_allowed_server_commonNames", "seaweed-volume")
addr := startTestGrpcServer(t, v, "grpc.volume")
if err := healthCheck(t, addr, LoadClientTLSForTarget(v, "grpc.filer", "grpc.volume")); err != nil {
t.Fatalf("health check with client-scoped target allowlist failed: %v", err)
}
}
func TestLoadClientTLSForTargetIgnoresTargetOutboundAllowlist(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
serverCert, serverKey := ca.issue(t, dir, "seaweed-volume", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
clientCert, clientKey := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", ca.file)
v.Set("grpc.volume.cert", serverCert)
v.Set("grpc.volume.key", serverKey)
v.Set("grpc.volume.allowed_server_commonNames", "seaweed-master")
v.Set("grpc.filer.client_cert", clientCert)
v.Set("grpc.filer.client_key", clientKey)
addr := startTestGrpcServer(t, v, "grpc.volume")
if err := healthCheck(t, addr, LoadClientTLSForTarget(v, "grpc.filer", "grpc.volume")); err != nil {
t.Fatalf("health check must not apply the target's outbound allowlist: %v", err)
}
}
func TestLoadClientTLSForTargetFallsBackToClientAllowlist(t *testing.T) {
dir := t.TempDir()
ca := newTestCA(t, dir)
serverCert, serverKey := ca.issue(t, dir, "seaweed-volume", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
clientCert, clientKey := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
v := util.NewViperProxy(viper.New())
v.Set("grpc.ca", ca.file)
v.Set("grpc.volume.cert", serverCert)
v.Set("grpc.volume.key", serverKey)
v.Set("grpc.filer.client_cert", clientCert)
v.Set("grpc.filer.client_key", clientKey)
v.Set("grpc.filer.allowed_server_commonNames", "seaweed-volume")
addr := startTestGrpcServer(t, v, "grpc.volume")
if err := healthCheck(t, addr, LoadClientTLSForTarget(v, "grpc.filer", "grpc.volume")); err != nil {
t.Fatalf("health check with client component allowlist failed: %v", err)
}
}