diff --git a/web/flags_test.go b/web/flags_test.go index e72c6d65..34acad0f 100644 --- a/web/flags_test.go +++ b/web/flags_test.go @@ -80,6 +80,25 @@ func TestCheckFlags(t *testing.T) { }, want: ErrNoListeners, }, + { + name: "both web config file and injected web config set", + flags: &FlagConfig{ + WebListenAddresses: &[]string{":9100"}, + WebSystemdSocket: OfBool(false), + WebConfigFile: OfString("testdata/web_config_noAuth.good.yml"), + WebConfig: &Config{}, + }, + want: ErrConflictingFlagsInConfigs, + }, + { + name: "only injected web config set", + flags: &FlagConfig{ + WebListenAddresses: &[]string{":9100"}, + WebSystemdSocket: OfBool(false), + WebConfig: &Config{}, + }, + want: nil, + }, } { t.Run(tc.name, func(t *testing.T) { if err := tc.flags.checkFlags(); !errors.Is(err, tc.want) { diff --git a/web/handler.go b/web/handler.go index 0a2718d5..3be436ea 100644 --- a/web/handler.go +++ b/web/handler.go @@ -38,14 +38,9 @@ var extraHTTPHeaders = map[string][]string{ "Content-Security-Policy": nil, } -func validateUsers(configPath string) error { - c, err := getConfig(configPath) - if err != nil { - return err - } - +func validateUsers(c *Config) error { for _, p := range c.Users { - _, err = bcrypt.Cost([]byte(p)) + _, err := bcrypt.Cost([]byte(p)) if err != nil { return err } @@ -78,6 +73,7 @@ HeadersLoop: type webHandler struct { tlsConfigPath string + config *Config handler http.Handler logger *slog.Logger cache *cache @@ -88,11 +84,18 @@ type webHandler struct { } func (u *webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - c, err := getConfig(u.tlsConfigPath) - if err != nil { - u.logger.Error("Unable to parse configuration", "err", err.Error()) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return + var c *Config + var err error + + if u.config == nil { + c, err = getConfig(u.tlsConfigPath) + if err != nil { + u.logger.Error("Unable to parse configuration", "err", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + } else { + c = u.config } if u.limiter != nil && !u.limiter.Allow() { diff --git a/web/tls_config.go b/web/tls_config.go index cf8b5718..17f0f03f 100644 --- a/web/tls_config.go +++ b/web/tls_config.go @@ -38,9 +38,10 @@ import ( ) var ( - errNoTLSConfig = errors.New("TLS config is not present") - ErrMissingFlag = errors.New("missing required flag configuration") - ErrNoListeners = errors.New("no web listen address or systemd socket flag specified") + errNoTLSConfig = errors.New("TLS config is not present") + ErrMissingFlag = errors.New("missing required flag configuration") + ErrNoListeners = errors.New("no web listen address or systemd socket flag specified") + ErrConflictingFlagsInConfigs = errors.New("conflicting flag configuration in TLS config. Both a config file path and an injected config cannot be provided at the same time.") ) type Config struct { @@ -77,8 +78,19 @@ type FlagConfig struct { WebListenAddresses *[]string // WebSystemdSocket enables systemd socket activation listeners. WebSystemdSocket *bool - // WebConfigFile points to the TLS and authentication configuration file. + // WebConfigFile is the optional path to the TLS config file. Either this or + // WebConfig must be set. + // + // TLS MinVersion and MaxVersion default to TLS 1.2 and TLS 1.3 when unset. + // HTTP2 is enabled if the HTTPConfig.HTTP2 field is left unset. WebConfigFile *string + // WebConfig is an optional configuration. Either this or WebConfigFile must be set. + // + // TLS MinVersion and MaxVersion default to TLS 1.2 and TLS 1.3 when unset. + // Other fields are used as provided (in particular HTTP/2 is only enabled + // when HTTPConfig.HTTP2 is set to true, unlike WebConfigFile which enables + // HTTP/2 automatically if left unset). + WebConfig *Config } // checkFlags validates that the flag configuration contains the required @@ -87,9 +99,14 @@ func (c *FlagConfig) checkFlags() error { if c == nil { return ErrMissingFlag } - if c.WebConfigFile == nil { + // Either a config file path or an injected config must be provided. + if c.WebConfigFile == nil && c.WebConfig == nil { return ErrMissingFlag } + // Both a config file path and an injected config cannot be provided at the same time. + if c.WebConfigFile != nil && c.WebConfig != nil { + return ErrConflictingFlagsInConfigs + } // Listen addresses are only optional when systemd socket activation is // actually enabled. Checking that WebSystemdSocket is non-nil is not // enough: kingpinflag.AddFlags always hands out a non-nil pointer, so a @@ -167,9 +184,8 @@ func getConfig(configPath string) (*Config, error) { } c := &Config{ TLSConfig: TLSConfig{ - MinVersion: tls.VersionTLS12, - MaxVersion: tls.VersionTLS13, - PreferServerCipherSuites: true, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS13, }, HTTPConfig: HTTPConfig{HTTP2: true}, } @@ -186,6 +202,11 @@ func getTLSConfig(configPath string) (*tls.Config, error) { if err != nil { return nil, err } + + if err := ValidateWebConfig(c); err != nil { + return nil, err + } + return ConfigToTLSConfig(&c.TLSConfig) } @@ -245,11 +266,21 @@ func ConfigToTLSConfig(c *TLSConfig) (*tls.Config, error) { return nil, err } - // c.PreferServerCipherSuites is deliberately not passed on: the tls.Config - // field of that name has had no effect since Go 1.17. + // Default the TLS versions without mutating the caller's config. An unset + // (zero) MinVersion/MaxVersion means "use the default" rather than crypto/tls' + // own zero-value defaults, so resolve them here into the derived tls.Config. + minVersion := c.MinVersion + if minVersion == 0 { + minVersion = tls.VersionTLS12 + } + maxVersion := c.MaxVersion + if maxVersion == 0 { + maxVersion = tls.VersionTLS13 + } + cfg := &tls.Config{ - MinVersion: (uint16)(c.MinVersion), - MaxVersion: (uint16)(c.MaxVersion), + MinVersion: (uint16)(minVersion), + MaxVersion: (uint16)(maxVersion), } cfg.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) { @@ -393,13 +424,27 @@ func parseVsockPort(address string) (uint32, error) { // WebConfigFile in the FlagConfig, TLS or basic auth could be enabled. func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog.Logger) error { logger.Info("Listening on", "address", l.Addr().String()) - tlsConfigPath := *flags.WebConfigFile - if tlsConfigPath == "" { - logger.Info("TLS is disabled.", "http2", false, "address", l.Addr().String()) - return server.Serve(l) + var c *Config + var err error + + // Determine which configuration to use based on the provided flags. + // checkConfigFlags has ensured exactly one of WebConfigFile / WebConfig is set. + if flags.WebConfigFile != nil { + tlsConfigPath := *flags.WebConfigFile + if tlsConfigPath == "" { + logger.Info("TLS is disabled.", "http2", false, "address", l.Addr().String()) + return server.Serve(l) + } + + c, err = getConfig(tlsConfigPath) + if err != nil { + return err + } + } else { + c = flags.WebConfig } - if err := validateUsers(tlsConfigPath); err != nil { + if err := ValidateWebConfig(c); err != nil { return err } @@ -409,11 +454,6 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. handler = server.Handler } - c, err := getConfig(tlsConfigPath) - if err != nil { - return err - } - var limiter *rate.Limiter if c.RateLimiterConfig.Interval != 0 { limiter = rate.NewLimiter(rate.Every(c.RateLimiterConfig.Interval), c.RateLimiterConfig.Burst) @@ -421,11 +461,11 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. } server.Handler = &webHandler{ - tlsConfigPath: tlsConfigPath, - logger: logger, - handler: handler, - cache: newCache(), - limiter: limiter, + config: c, + logger: logger, + handler: handler, + cache: newCache(), + limiter: limiter, } config, err := ConfigToTLSConfig(&c.TLSConfig) @@ -450,12 +490,31 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. // Set the GetConfigForClient method of the HTTPS server so that the config // and certs are reloaded on new connections. server.TLSConfig.GetConfigForClient = func(*tls.ClientHelloInfo) (*tls.Config, error) { - config, err := getTLSConfig(tlsConfigPath) - if err != nil { - return nil, err + var tlsConfig *tls.Config + var err error + // Determine which configuration to use based on the provided flags. + // checkFlags has ensured exactly one of + // WebConfigFile / WebConfig is set. + if flags.WebConfigFile != nil { + tlsConfigPath := *flags.WebConfigFile + + tlsConfig, err = getTLSConfig(tlsConfigPath) + if err != nil { + return nil, err + } + } else { + err = ValidateWebConfig(flags.WebConfig) + if err != nil { + return nil, err + } + // Use the provided config. + tlsConfig, err = ConfigToTLSConfig(&flags.WebConfig.TLSConfig) + if err != nil { + return nil, err + } } - config.NextProtos = tlsNextProtos(server, c.HTTPConfig.HTTP2) - return config, nil + tlsConfig.NextProtos = tlsNextProtos(server, c.HTTPConfig.HTTP2) + return tlsConfig, nil } return server.ServeTLS(l, "", "") } @@ -493,18 +552,28 @@ func Validate(tlsConfigPath string) error { if tlsConfigPath == "" { return nil } - if err := validateUsers(tlsConfigPath); err != nil { - return err - } c, err := getConfig(tlsConfigPath) if err != nil { return err } - _, err = ConfigToTLSConfig(&c.TLSConfig) - if err == errNoTLSConfig { + return ValidateWebConfig(c) +} + +// ValidateWebConfig validates the web configuration, including the TLS config and HTTP headers. +func ValidateWebConfig(config *Config) error { + if config == nil { return nil } - return err + if err := validateUsers(config); err != nil { + return err + } + if err := validateHeaderConfig(config.HTTPConfig.Header); err != nil { + return err + } + if _, err := ConfigToTLSConfig(&config.TLSConfig); err != nil && err != errNoTLSConfig { + return err + } + return nil } type Cipher uint16 diff --git a/web/tls_config_test.go b/web/tls_config_test.go index b8b3a966..0503b4ed 100644 --- a/web/tls_config_test.go +++ b/web/tls_config_test.go @@ -27,6 +27,8 @@ import ( "sync" "testing" "time" + + "go.yaml.in/yaml/v2" ) // Helpers for literal FlagConfig @@ -87,6 +89,7 @@ type TestInputs struct { Name string Server func() *http.Server YAMLConfigPath string + WebConfig bool ExpectedError *regexp.Regexp UseTLSClient bool ClientMaxTLSVersion uint16 @@ -105,6 +108,7 @@ func TestYAMLFiles(t *testing.T) { Name: `path to config yml invalid`, YAMLConfigPath: "somefile", ExpectedError: ErrorMap["No such file"], + WebConfig: false, }, { Name: `empty config yml`, @@ -195,6 +199,9 @@ func TestYAMLFiles(t *testing.T) { for _, testInputs := range testTables { t.Run("run/"+testInputs.Name, testInputs.Test) t.Run("validate/"+testInputs.Name, testInputs.TestValidate) + if testInputs.WebConfig { + t.Run("validateWebConfig/"+testInputs.Name, testInputs.TestValidateWebConfig) + } } } @@ -205,6 +212,10 @@ func TestServerBehaviour(t *testing.T) { YAMLConfigPath: "", ExpectedError: nil, }, + { + Name: `default client`, + ExpectedError: nil, + }, { Name: `empty string YAMLConfigPath and TLS client`, YAMLConfigPath: "", @@ -216,18 +227,38 @@ func TestServerBehaviour(t *testing.T) { YAMLConfigPath: "testdata/web_config_noAuth.good.yml", ExpectedError: ErrorMap["HTTP Request to HTTPS server"], }, + { + Name: `WebConfig: valid tls config and default client`, + YAMLConfigPath: "testdata/web_config_noAuth.good.yml", + WebConfig: true, + ExpectedError: ErrorMap["HTTP Request to HTTPS server"], + }, { Name: `valid tls config yml and tls client`, YAMLConfigPath: "testdata/web_config_noAuth.good.yml", UseTLSClient: true, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config and tls client`, + YAMLConfigPath: "testdata/web_config_noAuth.good.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: nil, + }, { Name: `valid tls config yml (cert and key inline) and tls client`, YAMLConfigPath: "testdata/web_config_noAuth_tlsInline.good.yml", UseTLSClient: true, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config (cert and key inline) and tls client`, + YAMLConfigPath: "testdata/web_config_noAuth_tlsInline.good.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: nil, + }, { Name: `valid tls config yml with TLS 1.1 client`, YAMLConfigPath: "testdata/web_config_noAuth.good.yml", @@ -235,12 +266,27 @@ func TestServerBehaviour(t *testing.T) { ClientMaxTLSVersion: tls.VersionTLS11, ExpectedError: ErrorMap["Incompatible TLS version"], }, + { + Name: `WebConfig: valid tls config with TLS 1.1 client`, + YAMLConfigPath: "testdata/web_config_noAuth.good.yml", + WebConfig: true, + UseTLSClient: true, + ClientMaxTLSVersion: tls.VersionTLS11, + ExpectedError: ErrorMap["Incompatible TLS version"], + }, { Name: `valid tls config yml with all ciphers`, YAMLConfigPath: "testdata/web_config_noAuth_allCiphers.good.yml", UseTLSClient: true, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with all ciphers`, + YAMLConfigPath: "testdata/web_config_noAuth_allCiphers.good.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: nil, + }, { Name: `valid tls config yml with some ciphers`, YAMLConfigPath: "testdata/web_config_noAuth_someCiphers.good.yml", @@ -248,6 +294,14 @@ func TestServerBehaviour(t *testing.T) { CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with some ciphers`, + YAMLConfigPath: "testdata/web_config_noAuth_someCiphers.good.yml", + WebConfig: true, + UseTLSClient: true, + CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, + ExpectedError: nil, + }, { Name: `valid tls config yml with no common cipher`, YAMLConfigPath: "testdata/web_config_noAuth_someCiphers.good.yml", @@ -255,6 +309,14 @@ func TestServerBehaviour(t *testing.T) { CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA}, ExpectedError: ErrorMap["Handshake failure"], }, + { + Name: `WebConfig: valid tls config with no common cipher`, + YAMLConfigPath: "testdata/web_config_noAuth_someCiphers.good.yml", + WebConfig: true, + UseTLSClient: true, + CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA}, + ExpectedError: ErrorMap["Handshake failure"], + }, { Name: `valid tls config yml with multiple client ciphers`, YAMLConfigPath: "testdata/web_config_noAuth_someCiphers.good.yml", @@ -266,6 +328,18 @@ func TestServerBehaviour(t *testing.T) { ActualCipher: tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with multiple client ciphers`, + YAMLConfigPath: "testdata/web_config_noAuth_someCiphers.good.yml", + WebConfig: true, + UseTLSClient: true, + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + }, + ActualCipher: tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + ExpectedError: nil, + }, { Name: `valid tls config yml with multiple client ciphers, client chooses cipher`, YAMLConfigPath: "testdata/web_config_noAuth_someCiphers_noOrder.good.yml", @@ -277,12 +351,31 @@ func TestServerBehaviour(t *testing.T) { ActualCipher: tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with multiple client ciphers, client chooses cipher`, + YAMLConfigPath: "testdata/web_config_noAuth_someCiphers_noOrder.good.yml", + WebConfig: true, + UseTLSClient: true, + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + }, + ActualCipher: tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + ExpectedError: nil, + }, { Name: `valid tls config yml with all curves`, YAMLConfigPath: "testdata/web_config_noAuth_allCurves.good.yml", UseTLSClient: true, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with all curves`, + YAMLConfigPath: "testdata/web_config_noAuth_allCurves.good.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: nil, + }, { Name: `valid tls config yml with some curves`, YAMLConfigPath: "testdata/web_config_noAuth_someCurves.good.yml", @@ -290,6 +383,14 @@ func TestServerBehaviour(t *testing.T) { CurvePreferences: []tls.CurveID{tls.CurveP521}, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with some curves`, + YAMLConfigPath: "testdata/web_config_noAuth_someCurves.good.yml", + WebConfig: true, + UseTLSClient: true, + CurvePreferences: []tls.CurveID{tls.CurveP521}, + ExpectedError: nil, + }, { Name: `valid tls config yml with no common curves`, YAMLConfigPath: "testdata/web_config_noAuth_someCurves.good.yml", @@ -297,37 +398,82 @@ func TestServerBehaviour(t *testing.T) { CurvePreferences: []tls.CurveID{tls.CurveP384}, ExpectedError: ErrorMap["Handshake failure"], }, + { + Name: `WebConfig: valid tls config with no common curves`, + YAMLConfigPath: "testdata/web_config_noAuth_someCurves.good.yml", + WebConfig: true, + UseTLSClient: true, + CurvePreferences: []tls.CurveID{tls.CurveP384}, + ExpectedError: ErrorMap["Handshake failure"], + }, { Name: `valid tls config yml with non-http2 ciphers`, YAMLConfigPath: "testdata/web_config_noAuth_noHTTP2.good.yml", UseTLSClient: true, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with non-http2 ciphers`, + YAMLConfigPath: "testdata/web_config_noAuth_noHTTP2.good.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: nil, + }, { Name: `valid tls config yml with non-http2 ciphers but http2 enabled`, YAMLConfigPath: "testdata/web_config_noAuth_noHTTP2Cipher.bad.yml", UseTLSClient: true, ExpectedError: ErrorMap["No HTTP2 cipher"], }, + { + Name: `WebConfig: valid tls config with non-http2 ciphers but http2 enabled`, + YAMLConfigPath: "testdata/web_config_noAuth_noHTTP2Cipher.bad.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: ErrorMap["No HTTP2 cipher"], + }, { Name: `valid headers config`, YAMLConfigPath: "testdata/web_config_headers.good.yml", }, + { + Name: `WebConfig: valid headers config`, + YAMLConfigPath: "testdata/web_config_headers.good.yml", + WebConfig: true, + }, { Name: `invalid X-Content-Type-Options headers config`, YAMLConfigPath: "testdata/web_config_headers_content_type_options.bad.yml", ExpectedError: ErrorMap["Invalid value"], }, + { + Name: `WebConfig: invalid X-Content-Type-Options headers config`, + YAMLConfigPath: "testdata/web_config_headers_content_type_options.bad.yml", + WebConfig: true, + ExpectedError: ErrorMap["Invalid value"], + }, { Name: `invalid X-Frame-Options headers config`, YAMLConfigPath: "testdata/web_config_headers_frame_options.bad.yml", ExpectedError: ErrorMap["Invalid value"], }, + { + Name: `WebConfig: invalid X-Frame-Options headers config`, + YAMLConfigPath: "testdata/web_config_headers_frame_options.bad.yml", + WebConfig: true, + ExpectedError: ErrorMap["Invalid value"], + }, { Name: `HTTP header that can not be overridden`, YAMLConfigPath: "testdata/web_config_headers_extra_header.bad.yml", ExpectedError: ErrorMap["Invalid header"], }, + { + Name: `WebConfig: HTTP header that can not be overridden`, + YAMLConfigPath: "testdata/web_config_headers_extra_header.bad.yml", + WebConfig: true, + ExpectedError: ErrorMap["Invalid header"], + }, { Name: `valid tls config yml and tls client with RequireAnyClientCert (present certificate)`, YAMLConfigPath: "testdata/tls_config_noAuth.requireanyclientcert.good.yml", @@ -335,6 +481,14 @@ func TestServerBehaviour(t *testing.T) { ClientCertificate: "client_selfsigned", ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config and tls client with RequireAnyClientCert (present certificate)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireanyclientcert.good.yml", + WebConfig: true, + UseTLSClient: true, + ClientCertificate: "client_selfsigned", + ExpectedError: nil, + }, { Name: `valid tls config yml (cert from file, key inline) and tls client with RequireAnyClientCert (present certificate)`, YAMLConfigPath: "testdata/tls_config_noAuth.requireanyclientcert.good.yml", @@ -342,6 +496,14 @@ func TestServerBehaviour(t *testing.T) { ClientCertificate: "client_selfsigned", ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config (cert from file, key inline) and tls client with RequireAnyClientCert (present certificate)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireanyclientcert.good.yml", + WebConfig: true, + UseTLSClient: true, + ClientCertificate: "client_selfsigned", + ExpectedError: nil, + }, { Name: `valid tls config yml and tls client with RequireAndVerifyClientCert (present certificate)`, YAMLConfigPath: "testdata/tls_config_noAuth.requireandverifyclientcert.good.yml", @@ -349,6 +511,14 @@ func TestServerBehaviour(t *testing.T) { ClientCertificate: "client_selfsigned", ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config and tls client with RequireAndVerifyClientCert (present certificate)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireandverifyclientcert.good.yml", + WebConfig: true, + UseTLSClient: true, + ClientCertificate: "client_selfsigned", + ExpectedError: nil, + }, { Name: `valid tls config yml and tls client with VerifyPeerCertificate (present good SAN DNS entry)`, YAMLConfigPath: "testdata/web_config_auth_client_san.good.yaml", @@ -356,6 +526,14 @@ func TestServerBehaviour(t *testing.T) { ClientCertificate: "client2_selfsigned", ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config and tls client with VerifyPeerCertificate (present good SAN DNS entry)`, + YAMLConfigPath: "testdata/web_config_auth_client_san.good.yaml", + WebConfig: true, + UseTLSClient: true, + ClientCertificate: "client2_selfsigned", + ExpectedError: nil, + }, { Name: `valid tls config yml and tls client with VerifyPeerCertificate (present invalid SAN DNS entries)`, YAMLConfigPath: "testdata/web_config_auth_client_san.bad.yaml", @@ -366,10 +544,26 @@ func TestServerBehaviour(t *testing.T) { { Name: "valid rate limiter (no rate limiter set up) that doesn't block", YAMLConfigPath: "testdata/web_config_rate_limiter_nonblocking.yaml", + WebConfig: true, UseTLSClient: false, Requests: 10, ExpectedError: nil, }, + { + Name: "valid rate limiter (no rate limiter set up) that doesn't block", + YAMLConfigPath: "testdata/web_config_rate_limiter_nonblocking.yaml", + UseTLSClient: false, + Requests: 10, + ExpectedError: nil, + }, + { + Name: "valid rate limiter with an interval of one second", + YAMLConfigPath: "testdata/web_config_rate_limiter_one_second.yaml", + WebConfig: true, + UseTLSClient: false, + Requests: 10, + ExpectedError: ErrorMap["Too Many Requests"], + }, { Name: "valid rate limiter with an interval of one second", YAMLConfigPath: "testdata/web_config_rate_limiter_one_second.yaml", @@ -464,6 +658,105 @@ func TestConfigReloading(t *testing.T) { } } +// TestWebConfigReloading mirrors TestConfigReloading but for an injected +// FlagConfig.WebConfig instead of a WebConfigFile. There is no file to swap, so +// the "reload" is an in-place mutation of flags.WebConfig: Serve's +// GetConfigForClient callback re-reads it on every new connection. The test +// starts with a blocking client-auth policy (RequireAndVerifyClientCert), which +// rejects a client that sends no certificate, then relaxes it to +// VerifyClientCertIfGiven and confirms a new connection is accepted without a +// restart. +func TestWebConfigReloading(t *testing.T) { + errorChannel := make(chan error, 1) + var once sync.Once + recordConnectionError := func(err error) { + once.Do(func() { + errorChannel <- err + }) + } + defer func() { + if recover() != nil { + recordConnectionError(errors.New("Panic in test function")) + } + }() + + goodYAMLPath := "testdata/web_config_noAuth.good.yml" + webConfigGood, err := getConfig(goodYAMLPath) + if err != nil { + t.Fatal(err) + } + badYAMLPath := "testdata/web_config_noAuth.good.blocking.yml" + webConfigBad, err := getConfig(badYAMLPath) + if err != nil { + t.Fatal(err) + } + + localPort := getPort() + + server := &http.Server{ + Addr: localPort, + Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("Hello World!")) + }), + } + defer func() { + server.Close() + }() + + flags := FlagConfig{ + WebListenAddresses: &([]string{localPort}), + WebSystemdSocket: OfBool(false), + WebConfig: webConfigBad, + } + + go func() { + defer func() { + if recover() != nil { + recordConnectionError(errors.New("Panic starting server")) + } + }() + err := Listen(server, &flags, testlogger) + recordConnectionError(err) + }() + + client := getTLSClient("") + + TestClientConnection := func() error { + time.Sleep(250 * time.Millisecond) + r, err := client.Get("https://localhost" + localPort) + if err != nil { + return err + } + body, err := io.ReadAll(r.Body) + if err != nil { + return err + } + if string(body) != "Hello World!" { + return errors.New(string(body)) + } + return nil + } + + err = TestClientConnection() + if err == nil { + recordConnectionError(errors.New("connection accepted but should have failed")) + } else { + flags.WebConfig = webConfigGood + err = TestClientConnection() + if err != nil { + recordConnectionError(errors.New("connection failed but should have been accepted")) + } else { + + recordConnectionError(nil) + } + } + + err = <-errorChannel + if err != nil { + t.Errorf(" *** Failed test: %s *** Returned error: %v", "TestConfigReloading", err) + } +} + func (test *TestInputs) Test(t *testing.T) { errorChannel := make(chan error, 1) var once sync.Once @@ -493,7 +786,15 @@ func (test *TestInputs) Test(t *testing.T) { flags := FlagConfig{ WebListenAddresses: &([]string{port}), WebSystemdSocket: OfBool(false), - WebConfigFile: &test.YAMLConfigPath, + } + if test.WebConfig { + webConfig, err := getConfig(test.YAMLConfigPath) + if err != nil { + recordConnectionError(fmt.Errorf("Could not parse configuration: %v", err)) + } + flags.WebConfig = webConfig + } else { + flags.WebConfigFile = &test.YAMLConfigPath } err := ListenAndServe(server, &flags, testlogger) recordConnectionError(err) @@ -591,6 +892,39 @@ func (test *TestInputs) TestValidate(t *testing.T) { } } +func (test *TestInputs) TestValidateWebConfig(t *testing.T) { + content, err := os.ReadFile(test.YAMLConfigPath) + if err != nil { + t.Fatalf("Could not read configuration file: %v", err) + } + c := &Config{ + TLSConfig: TLSConfig{ + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS13, + PreferServerCipherSuites: true, + }, + HTTPConfig: HTTPConfig{HTTP2: true}, + } + err = yaml.UnmarshalStrict(content, c) + if err != nil { + t.Fatalf("Could not parse configuration: %v", err) + } + validationErr := ValidateWebConfig(c) + if test.ExpectedError == nil { + if validationErr != nil { + t.Errorf("Expected no error, got error: %v", validationErr) + } + return + } + if validationErr == nil { + t.Errorf("Got no error, expected: %v", test.ExpectedError) + return + } + if !test.ExpectedError.MatchString(validationErr.Error()) { + t.Errorf("Expected error %v, got error: %v", test.ExpectedError, validationErr) + } +} + func (test *TestInputs) isCorrectError(returnedError error) bool { switch { case returnedError == nil && test.ExpectedError == nil: @@ -662,6 +996,12 @@ func TestUsers(t *testing.T) { YAMLConfigPath: "testdata/web_config_users_noTLS.good.yml", ExpectedError: ErrorMap["Unauthorized"], }, + { + Name: `WebConfig: without basic auth`, + YAMLConfigPath: "testdata/web_config_users_noTLS.good.yml", + WebConfig: true, + ExpectedError: ErrorMap["Unauthorized"], + }, { Name: `with correct basic auth`, YAMLConfigPath: "testdata/web_config_users_noTLS.good.yml", @@ -669,12 +1009,27 @@ func TestUsers(t *testing.T) { Password: "dave123", ExpectedError: nil, }, + { + Name: `WebConfig: with correct basic auth`, + YAMLConfigPath: "testdata/web_config_users_noTLS.good.yml", + WebConfig: true, + Username: "dave", + Password: "dave123", + ExpectedError: nil, + }, { Name: `without basic auth and TLS`, YAMLConfigPath: "testdata/web_config_users.good.yml", UseTLSClient: true, ExpectedError: ErrorMap["Unauthorized"], }, + { + Name: `WebConfig: without basic auth and TLS`, + YAMLConfigPath: "testdata/web_config_users.good.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: ErrorMap["Unauthorized"], + }, { Name: `with correct basic auth and TLS`, YAMLConfigPath: "testdata/web_config_users.good.yml", @@ -683,6 +1038,15 @@ func TestUsers(t *testing.T) { Password: "dave123", ExpectedError: nil, }, + { + Name: `WebConfig: with correct basic auth and TLS`, + YAMLConfigPath: "testdata/web_config_users.good.yml", + WebConfig: true, + UseTLSClient: true, + Username: "dave", + Password: "dave123", + ExpectedError: nil, + }, { Name: `with another correct basic auth and TLS`, YAMLConfigPath: "testdata/web_config_users.good.yml", @@ -691,6 +1055,15 @@ func TestUsers(t *testing.T) { Password: "carol123", ExpectedError: nil, }, + { + Name: `WebConfig: with another correct basic auth and TLS`, + YAMLConfigPath: "testdata/web_config_users.good.yml", + WebConfig: true, + UseTLSClient: true, + Username: "carol", + Password: "carol123", + ExpectedError: nil, + }, { Name: `with bad password and TLS`, YAMLConfigPath: "testdata/web_config_users.good.yml", @@ -699,6 +1072,15 @@ func TestUsers(t *testing.T) { Password: "bad", ExpectedError: ErrorMap["Unauthorized"], }, + { + Name: `WebConfig: with bad password and TLS`, + YAMLConfigPath: "testdata/web_config_users.good.yml", + WebConfig: true, + UseTLSClient: true, + Username: "dave", + Password: "bad", + ExpectedError: ErrorMap["Unauthorized"], + }, { Name: `with bad username and TLS`, YAMLConfigPath: "testdata/web_config_users.good.yml", @@ -707,6 +1089,15 @@ func TestUsers(t *testing.T) { Password: "nonexistent", ExpectedError: ErrorMap["Unauthorized"], }, + { + Name: `WebConfig: with bad username and TLS`, + YAMLConfigPath: "testdata/web_config_users.good.yml", + WebConfig: true, + UseTLSClient: true, + Username: "nonexistent", + Password: "nonexistent", + ExpectedError: ErrorMap["Unauthorized"], + }, } for _, testInputs := range testTables { t.Run(testInputs.Name, testInputs.Test) @@ -757,3 +1148,63 @@ func TestTLSConfigIsEnabled(t *testing.T) { }) } } + +// TestConfigToTLSConfigVersionDefaults verifies that ConfigToTLSConfig resolves +// unset TLS MinVersion/MaxVersion to the defaults (TLS 1.2 / TLS 1.3) in the +// derived tls.Config, that explicitly set versions are preserved, and that the +// provided TLSConfig is never mutated. +func TestConfigToTLSConfigVersionDefaults(t *testing.T) { + for _, tc := range []struct { + name string + provided TLSConfig + expectedMinVersion uint16 + expectedMaxVersion uint16 + }{ + { + name: "unset versions get defaulted", + provided: TLSConfig{ + TLSCertPath: "testdata/server.crt", + TLSKeyPath: "testdata/server.key", + }, + expectedMinVersion: tls.VersionTLS12, + expectedMaxVersion: tls.VersionTLS13, + }, + { + name: "explicit versions are preserved", + provided: TLSConfig{ + TLSCertPath: "testdata/server.crt", + TLSKeyPath: "testdata/server.key", + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + }, + expectedMinVersion: tls.VersionTLS13, + expectedMaxVersion: tls.VersionTLS13, + }, + } { + t.Run(tc.name, func(t *testing.T) { + provided := tc.provided + originalMin := provided.MinVersion + originalMax := provided.MaxVersion + + cfg, err := ConfigToTLSConfig(&provided) + if err != nil { + t.Fatalf("ConfigToTLSConfig returned an error: %v", err) + } + + if cfg.MinVersion != tc.expectedMinVersion { + t.Errorf("derived MinVersion = %d, expected %d", cfg.MinVersion, tc.expectedMinVersion) + } + if cfg.MaxVersion != tc.expectedMaxVersion { + t.Errorf("derived MaxVersion = %d, expected %d", cfg.MaxVersion, tc.expectedMaxVersion) + } + + // The caller's config must not be mutated. + if provided.MinVersion != originalMin { + t.Errorf("provided MinVersion was mutated: got %d, want %d", provided.MinVersion, originalMin) + } + if provided.MaxVersion != originalMax { + t.Errorf("provided MaxVersion was mutated: got %d, want %d", provided.MaxVersion, originalMax) + } + }) + } +}