From dd19e4387ab54010116c65e878d3b9c8ca768061 Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Sun, 31 May 2026 22:11:29 +0800 Subject: [PATCH] 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. --- config/config.go | 3 + config/parsing/service/parse.go | 2 + internal/util/tls/tls.go | 30 ++++++++++ internal/util/tls/tls_test.go | 101 ++++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 internal/util/tls/tls_test.go diff --git a/config/config.go b/config/config.go index 17e4b29f..3bf3e326 100644 --- a/config/config.go +++ b/config/config.go @@ -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 { diff --git a/config/parsing/service/parse.go b/config/parsing/service/parse.go index 96359e41..c58a438e 100644 --- a/config/parsing/service/parse.go +++ b/config/parsing/service/parse.go @@ -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...) diff --git a/internal/util/tls/tls.go b/internal/util/tls/tls.go index 621e91cc..a1adf92a 100644 --- a/internal/util/tls/tls.go +++ b/internal/util/tls/tls.go @@ -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 diff --git a/internal/util/tls/tls_test.go b/internal/util/tls/tls_test.go new file mode 100644 index 00000000..721d0adc --- /dev/null +++ b/internal/util/tls/tls_test.go @@ -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") + } +} \ No newline at end of file