feat(tls): add rejectUnknownSNI support for TLS listeners

Adds RejectUnknownSNI and ServerNames fields to TLSConfig. When enabled,
TLS handshakes with missing or non-allowlisted SNI are rejected at the
handshake level via GetConfigForClient before any certificate is sent.

Works across all TLS-based listener types (tls, mtls, ws, mws, http2,
grpc, http3) since they share the same *tls.Config from LoadServerConfig.
This commit is contained in:
ginuerzh
2026-05-31 22:11:29 +08:00
parent 7cf7284339
commit dd19e4387a
4 changed files with 136 additions and 0 deletions
+3
View File
@@ -115,6 +115,9 @@ type TLSConfig struct {
Validity time.Duration `yaml:",omitempty" json:"validity,omitempty"`
CommonName string `yaml:"commonName,omitempty" json:"commonName,omitempty"`
Organization string `yaml:",omitempty" json:"organization,omitempty"`
RejectUnknownSNI bool `yaml:"rejectUnknownSNI,omitempty" json:"rejectUnknownSNI,omitempty"`
ServerNames []string `yaml:"serverNames,omitempty" json:"serverNames,omitempty"`
}
type TLSOptions struct {
+2
View File
@@ -84,6 +84,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
if tlsConfig == nil {
tlsConfig = parsing.DefaultTLSConfig().Clone()
tls_util.SetTLSOptions(tlsConfig, tlsCfg.Options)
tls_util.RejectUnknownSNIConfig(tlsConfig, tlsCfg.RejectUnknownSNI, tlsCfg.ServerNames)
}
authers := auth_parser.List(cfg.Listener.Auther, cfg.Listener.Authers...)
@@ -252,6 +253,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
if tlsConfig == nil {
tlsConfig = parsing.DefaultTLSConfig().Clone()
tls_util.SetTLSOptions(tlsConfig, tlsCfg.Options)
tls_util.RejectUnknownSNIConfig(tlsConfig, tlsCfg.RejectUnknownSNI, tlsCfg.ServerNames)
}
authers = auth_parser.List(cfg.Handler.Auther, cfg.Handler.Authers...)
+30
View File
@@ -165,6 +165,8 @@ func LoadServerConfig(config *config.TLSConfig) (*tls.Config, error) {
SetTLSOptions(cfg, config.Options)
RejectUnknownSNIConfig(cfg, config.RejectUnknownSNI, config.ServerNames)
return cfg, nil
}
@@ -454,6 +456,34 @@ func GenerateCertificate(serverName string, validity time.Duration, caCert *x509
return x509.ParseCertificate(raw)
}
// RejectUnknownSNIConfig sets a GetConfigForClient callback on cfg that rejects
// TLS handshakes when the SNI is missing or not in the allowed list.
// If allowList is empty, only missing/empty SNI is rejected (all named SNIs
// are allowed). If allowList is populated, any SNI not in the list is rejected.
func RejectUnknownSNIConfig(cfg *tls.Config, rejectUnknown bool, allowList []string) {
if !rejectUnknown {
return
}
cfg.GetConfigForClient = func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
if hello.ServerName == "" {
return nil, errors.New("SNI is required but not provided")
}
if len(allowList) > 0 {
found := false
for _, name := range allowList {
if strings.EqualFold(hello.ServerName, name) {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("SNI %q is not allowed", hello.ServerName)
}
}
return cfg, nil
}
}
// https://pkg.go.dev/crypto#PrivateKey
type privateKey interface {
Public() crypto.PublicKey
+101
View File
@@ -0,0 +1,101 @@
package tls
import (
"crypto/tls"
"testing"
)
func TestRejectUnknownSNIConfig_Disabled(t *testing.T) {
cfg := &tls.Config{}
RejectUnknownSNIConfig(cfg, false, nil)
if cfg.GetConfigForClient != nil {
t.Error("GetConfigForClient should be nil when rejectUnknownSNI is false")
}
}
func TestRejectUnknownSNIConfig_EmptyServerName(t *testing.T) {
cfg := &tls.Config{}
RejectUnknownSNIConfig(cfg, true, nil)
if cfg.GetConfigForClient == nil {
t.Fatal("GetConfigForClient should be set")
}
_, err := cfg.GetConfigForClient(&tls.ClientHelloInfo{})
if err == nil {
t.Error("expected error for empty ServerName")
}
}
func TestRejectUnknownSNIConfig_NamedAllowed(t *testing.T) {
cfg := &tls.Config{}
RejectUnknownSNIConfig(cfg, true, nil)
if cfg.GetConfigForClient == nil {
t.Fatal("GetConfigForClient should be set")
}
result, err := cfg.GetConfigForClient(&tls.ClientHelloInfo{ServerName: "example.com"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != cfg {
t.Error("expected the original config to be returned")
}
}
func TestRejectUnknownSNIConfig_WithAllowList_Match(t *testing.T) {
cfg := &tls.Config{}
RejectUnknownSNIConfig(cfg, true, []string{"example.com", "test.org"})
if cfg.GetConfigForClient == nil {
t.Fatal("GetConfigForClient should be set")
}
result, err := cfg.GetConfigForClient(&tls.ClientHelloInfo{ServerName: "example.com"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != cfg {
t.Error("expected the original config to be returned")
}
}
func TestRejectUnknownSNIConfig_WithAllowList_NoMatch(t *testing.T) {
cfg := &tls.Config{}
RejectUnknownSNIConfig(cfg, true, []string{"example.com", "test.org"})
if cfg.GetConfigForClient == nil {
t.Fatal("GetConfigForClient should be set")
}
_, err := cfg.GetConfigForClient(&tls.ClientHelloInfo{ServerName: "evil.com"})
if err == nil {
t.Error("expected error for non-matching ServerName")
}
}
func TestRejectUnknownSNIConfig_WithAllowList_CaseInsensitive(t *testing.T) {
cfg := &tls.Config{}
RejectUnknownSNIConfig(cfg, true, []string{"Example.COM"})
if cfg.GetConfigForClient == nil {
t.Fatal("GetConfigForClient should be set")
}
result, err := cfg.GetConfigForClient(&tls.ClientHelloInfo{ServerName: "example.com"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != cfg {
t.Error("expected the original config to be returned")
}
}
func TestRejectUnknownSNIConfig_WithAllowList_EmptySNI(t *testing.T) {
cfg := &tls.Config{}
RejectUnknownSNIConfig(cfg, true, []string{"example.com"})
if cfg.GetConfigForClient == nil {
t.Fatal("GetConfigForClient should be set")
}
_, err := cfg.GetConfigForClient(&tls.ClientHelloInfo{})
if err == nil {
t.Error("expected error for empty ServerName when allowList is set")
}
}