diff --git a/CHANGELOG.md b/CHANGELOG.md index ed864917403..c4505a58558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ * [BUGFIX] Packaging: Fix RPM and deb packages to install the binary to `/usr/bin`, install the systemd unit to the correct system path (`/usr/lib/systemd/system` for RPM, `/lib/systemd/system` for deb), and mark the sysconfig/default env file as a config file so it is not overwritten on upgrade. #7445 * [BUGFIX] Compactor: Handle not-found and access-denied errors from `Attributes()` in bucket index updater, preventing a stale cached `Get()` from causing the entire cleanup cycle to fail when `meta.json` has been deleted from object storage. #7454 * [BUGFIX] gRPC: Fix panic when `grpc_compression` is set to `snappy` on ingester client or store-gateway client configurations. #7459 +* [BUGFIX] Config: Mask Swift, etcd, Redis, and HTTP basic-auth credentials on the `/config` endpoint. #7473 ## 1.21.0 2026-04-24 diff --git a/pkg/cortex/modules_test.go b/pkg/cortex/modules_test.go index 7b70280b15b..77de5ff96e2 100644 --- a/pkg/cortex/modules_test.go +++ b/pkg/cortex/modules_test.go @@ -4,6 +4,9 @@ import ( "context" "net/http/httptest" "os" + "reflect" + "regexp" + "strings" "testing" "github.com/gorilla/mux" @@ -14,6 +17,7 @@ import ( "github.com/cortexproject/cortex/pkg/configs" "github.com/cortexproject/cortex/pkg/cortexpb" + "github.com/cortexproject/cortex/pkg/util/flagext" ) func changeTargetConfig(c *Config) { @@ -248,3 +252,104 @@ func Test_initResourceMonitor_shouldFailOnInvalidResource(t *testing.T) { _, err := cortex.initResourceMonitor() require.ErrorContains(t, err, "unknown resource type") } + +func TestConfigEndpoint_SecretsMasked(t *testing.T) { + cfg := newDefaultConfig() + + // Use reflection to find every flagext.Secret field in the config and set + // it to a sentinel value. This ensures newly added Secret fields are + // automatically covered without updating this test. + sentinel := "LEAKED_SECRET_VALUE" + setAllSecrets(reflect.ValueOf(cfg), sentinel) + + cortex := &Cortex{ + Server: &server.Server{}, + Cfg: *cfg, + } + cortex.Server.HTTP = mux.NewRouter() + + _, err := cortex.initAPI() + require.NoError(t, err) + + req := httptest.NewRequest("GET", "/config", nil) + resp := httptest.NewRecorder() + cortex.Server.HTTP.ServeHTTP(resp, req) + + require.Equal(t, 200, resp.Code) + + body := resp.Body.String() + + // Verify the sentinel never appears in cleartext. + assert.NotContains(t, body, sentinel, "a flagext.Secret value was leaked in /config output") + + // Verify at least one masked value is present (sanity check). + assert.Contains(t, body, "********", "expected masked secrets in /config output") +} + +// TestConfig_SensitiveFieldTypes verifies that every struct field in Config +// whose YAML tag name suggests a credential uses flagext.Secret, not string. +// This catches new password/secret fields added as plain strings even if they +// have no default value. +func TestConfig_SensitiveFieldTypes(t *testing.T) { + sensitivePattern := regexp.MustCompile(`(?i)^(password|secret|secret_key|application_credential_secret|basic_auth_password)$`) + secretType := reflect.TypeFor[flagext.Secret]() + + var violations []string + checkSensitiveFields(reflect.TypeFor[Config](), "", sensitivePattern, secretType, &violations) + + for _, v := range violations { + t.Errorf("field should use flagext.Secret, not string: %s", v) + } +} + +// checkSensitiveFields recursively walks a type and reports any string field +// whose YAML tag matches the sensitive pattern. +func checkSensitiveFields(t reflect.Type, prefix string, pattern *regexp.Regexp, secretType reflect.Type, violations *[]string) { + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return + } + for f := range t.Fields() { + path := prefix + f.Name + + yamlTag := f.Tag.Get("yaml") + yamlName := strings.Split(yamlTag, ",")[0] + + if pattern.MatchString(yamlName) && f.Type.Kind() == reflect.String { + *violations = append(*violations, path+" (yaml:\""+yamlName+"\")") + } + + ft := f.Type + if ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct && ft != secretType { + checkSensitiveFields(ft, path+".", pattern, secretType, violations) + } + } +} + +// setAllSecrets recursively walks a reflect.Value and sets every flagext.Secret +// field's Value to the given sentinel string. +func setAllSecrets(v reflect.Value, sentinel string) { + switch v.Kind() { + case reflect.Ptr: + if !v.IsNil() { + setAllSecrets(v.Elem(), sentinel) + } + case reflect.Struct: + secretType := reflect.TypeFor[flagext.Secret]() + for _, f := range v.Fields() { + if !f.CanSet() { + continue + } + if f.Type() == secretType { + f.Set(reflect.ValueOf(flagext.Secret{Value: sentinel})) + } else { + setAllSecrets(f, sentinel) + } + } + } +} diff --git a/pkg/ring/kv/etcd/etcd.go b/pkg/ring/kv/etcd/etcd.go index 1152bff5f75..55b6221cd9f 100644 --- a/pkg/ring/kv/etcd/etcd.go +++ b/pkg/ring/kv/etcd/etcd.go @@ -27,9 +27,9 @@ type Config struct { EnableTLS bool `yaml:"tls_enabled"` TLS cortextls.ClientConfig `yaml:",inline"` - UserName string `yaml:"username"` - Password string `yaml:"password"` - PermitWithoutStream bool `yaml:"ping-without-stream-allowed"` + UserName string `yaml:"username"` + Password flagext.Secret `yaml:"password"` + PermitWithoutStream bool `yaml:"ping-without-stream-allowed"` } // Clientv3Facade is a subset of all Etcd client operations that are required @@ -59,7 +59,7 @@ func (cfg *Config) RegisterFlagsWithPrefix(f *flag.FlagSet, prefix string) { f.IntVar(&cfg.MaxRetries, prefix+"etcd.max-retries", 10, "The maximum number of retries to do for failed ops.") f.BoolVar(&cfg.EnableTLS, prefix+"etcd.tls-enabled", false, "Enable TLS.") f.StringVar(&cfg.UserName, prefix+"etcd.username", "", "Etcd username.") - f.StringVar(&cfg.Password, prefix+"etcd.password", "", "Etcd password.") + f.Var(&cfg.Password, prefix+"etcd.password", "Etcd password.") f.BoolVar(&cfg.PermitWithoutStream, prefix+"etcd.ping-without-stream-allowed", true, "Send Keepalive pings with no streams.") cfg.TLS.RegisterFlagsWithPrefix(prefix+"etcd", f) } @@ -107,7 +107,7 @@ func New(cfg Config, codec codec.Codec, logger log.Logger) (*Client, error) { PermitWithoutStream: cfg.PermitWithoutStream, TLS: tlsConfig, Username: cfg.UserName, - Password: cfg.Password, + Password: cfg.Password.Value, }) if err != nil { return nil, err diff --git a/pkg/ruler/notifier.go b/pkg/ruler/notifier.go index a904946dc0b..cbb234cc9c9 100644 --- a/pkg/ruler/notifier.go +++ b/pkg/ruler/notifier.go @@ -190,7 +190,7 @@ func amConfigFromURL(rulerConfig *Config, url *url.URL, apiVersion config.Alertm if rulerConfig.Notifier.BasicAuth.IsEnabled() { amConfig.HTTPClientConfig.BasicAuth = &config_util.BasicAuth{ Username: rulerConfig.Notifier.BasicAuth.Username, - Password: config_util.Secret(rulerConfig.Notifier.BasicAuth.Password), + Password: config_util.Secret(rulerConfig.Notifier.BasicAuth.Password.Value), } } diff --git a/pkg/ruler/notifier_test.go b/pkg/ruler/notifier_test.go index e27e3527ed7..b527b10f416 100644 --- a/pkg/ruler/notifier_test.go +++ b/pkg/ruler/notifier_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/cortexproject/cortex/pkg/util" + "github.com/cortexproject/cortex/pkg/util/flagext" ) func TestBuildNotifierConfig(t *testing.T) { @@ -195,7 +196,7 @@ func TestBuildNotifierConfig(t *testing.T) { Notifier: NotifierConfig{ BasicAuth: util.BasicAuth{ Username: "jacob", - Password: "test", + Password: flagext.Secret{Value: "test"}, }, }, }, diff --git a/pkg/storage/bucket/swift/bucket_client.go b/pkg/storage/bucket/swift/bucket_client.go index cd76cabfb6f..6a6a72b284a 100644 --- a/pkg/storage/bucket/swift/bucket_client.go +++ b/pkg/storage/bucket/swift/bucket_client.go @@ -16,12 +16,12 @@ func NewBucketClient(cfg Config, hedgedRoundTripper func(rt http.RoundTripper) h AuthUrl: cfg.AuthURL, ApplicationCredentialID: cfg.ApplicationCredentialID, ApplicationCredentialName: cfg.ApplicationCredentialName, - ApplicationCredentialSecret: cfg.ApplicationCredentialSecret, + ApplicationCredentialSecret: cfg.ApplicationCredentialSecret.Value, Username: cfg.Username, UserDomainName: cfg.UserDomainName, UserDomainID: cfg.UserDomainID, UserId: cfg.UserID, - Password: cfg.Password, + Password: cfg.Password.Value, DomainId: cfg.DomainID, DomainName: cfg.DomainName, ProjectID: cfg.ProjectID, diff --git a/pkg/storage/bucket/swift/config.go b/pkg/storage/bucket/swift/config.go index 110da50308f..8c49afc3b9d 100644 --- a/pkg/storage/bucket/swift/config.go +++ b/pkg/storage/bucket/swift/config.go @@ -3,31 +3,33 @@ package swift import ( "flag" "time" + + "github.com/cortexproject/cortex/pkg/util/flagext" ) // Config holds the config options for Swift backend type Config struct { - AuthVersion int `yaml:"auth_version"` - AuthURL string `yaml:"auth_url"` - ApplicationCredentialID string `yaml:"application_credential_id"` - ApplicationCredentialName string `yaml:"application_credential_name"` - ApplicationCredentialSecret string `yaml:"application_credential_secret"` - Username string `yaml:"username"` - UserDomainName string `yaml:"user_domain_name"` - UserDomainID string `yaml:"user_domain_id"` - UserID string `yaml:"user_id"` - Password string `yaml:"password"` - DomainID string `yaml:"domain_id"` - DomainName string `yaml:"domain_name"` - ProjectID string `yaml:"project_id"` - ProjectName string `yaml:"project_name"` - ProjectDomainID string `yaml:"project_domain_id"` - ProjectDomainName string `yaml:"project_domain_name"` - RegionName string `yaml:"region_name"` - ContainerName string `yaml:"container_name"` - MaxRetries int `yaml:"max_retries"` - ConnectTimeout time.Duration `yaml:"connect_timeout"` - RequestTimeout time.Duration `yaml:"request_timeout"` + AuthVersion int `yaml:"auth_version"` + AuthURL string `yaml:"auth_url"` + ApplicationCredentialID string `yaml:"application_credential_id"` + ApplicationCredentialName string `yaml:"application_credential_name"` + ApplicationCredentialSecret flagext.Secret `yaml:"application_credential_secret"` + Username string `yaml:"username"` + UserDomainName string `yaml:"user_domain_name"` + UserDomainID string `yaml:"user_domain_id"` + UserID string `yaml:"user_id"` + Password flagext.Secret `yaml:"password"` + DomainID string `yaml:"domain_id"` + DomainName string `yaml:"domain_name"` + ProjectID string `yaml:"project_id"` + ProjectName string `yaml:"project_name"` + ProjectDomainID string `yaml:"project_domain_id"` + ProjectDomainName string `yaml:"project_domain_name"` + RegionName string `yaml:"region_name"` + ContainerName string `yaml:"container_name"` + MaxRetries int `yaml:"max_retries"` + ConnectTimeout time.Duration `yaml:"connect_timeout"` + RequestTimeout time.Duration `yaml:"request_timeout"` } // RegisterFlags registers the flags for Swift storage @@ -43,7 +45,7 @@ func (cfg *Config) RegisterFlagsWithPrefix(prefix string, f *flag.FlagSet) { f.StringVar(&cfg.UserDomainName, prefix+"swift.user-domain-name", "", "OpenStack Swift user's domain name.") f.StringVar(&cfg.UserDomainID, prefix+"swift.user-domain-id", "", "OpenStack Swift user's domain ID.") f.StringVar(&cfg.UserID, prefix+"swift.user-id", "", "OpenStack Swift user ID.") - f.StringVar(&cfg.Password, prefix+"swift.password", "", "OpenStack Swift API key.") + f.Var(&cfg.Password, prefix+"swift.password", "OpenStack Swift API key.") f.StringVar(&cfg.DomainID, prefix+"swift.domain-id", "", "OpenStack Swift user's domain ID.") f.StringVar(&cfg.DomainName, prefix+"swift.domain-name", "", "OpenStack Swift user's domain name.") f.StringVar(&cfg.ProjectID, prefix+"swift.project-id", "", "OpenStack Swift project ID (v2,v3 auth only).") @@ -52,7 +54,7 @@ func (cfg *Config) RegisterFlagsWithPrefix(prefix string, f *flag.FlagSet) { f.StringVar(&cfg.ProjectDomainName, prefix+"swift.project-domain-name", "", "Name of the OpenStack Swift project's domain (v3 auth only), only needed if it differs from the user domain.") f.StringVar(&cfg.ApplicationCredentialID, prefix+"swift.application-credential-id", "", "OpenStack Swift application credential ID.") f.StringVar(&cfg.ApplicationCredentialName, prefix+"swift.application-credential-name", "", "OpenStack Swift application credential name.") - f.StringVar(&cfg.ApplicationCredentialSecret, prefix+"swift.application-credential-secret", "", "OpenStack Swift application credential secret.") + f.Var(&cfg.ApplicationCredentialSecret, prefix+"swift.application-credential-secret", "OpenStack Swift application credential secret.") f.StringVar(&cfg.RegionName, prefix+"swift.region-name", "", "OpenStack Swift Region to use (v2,v3 auth only).") f.StringVar(&cfg.ContainerName, prefix+"swift.container-name", "", "Name of the OpenStack Swift container to put chunks in.") f.IntVar(&cfg.MaxRetries, prefix+"swift.max-retries", 3, "Max retries on requests error.") diff --git a/pkg/storage/tsdb/redis_client_config.go b/pkg/storage/tsdb/redis_client_config.go index deb871f0b98..f72cc00b6ad 100644 --- a/pkg/storage/tsdb/redis_client_config.go +++ b/pkg/storage/tsdb/redis_client_config.go @@ -8,15 +8,16 @@ import ( "github.com/thanos-io/thanos/pkg/cacheutil" "github.com/thanos-io/thanos/pkg/model" + "github.com/cortexproject/cortex/pkg/util/flagext" "github.com/cortexproject/cortex/pkg/util/tls" ) type RedisClientConfig struct { - Addresses string `yaml:"addresses"` - Username string `yaml:"username"` - Password string `yaml:"password"` - DB int `yaml:"db"` - MasterName string `yaml:"master_name"` + Addresses string `yaml:"addresses"` + Username string `yaml:"username"` + Password flagext.Secret `yaml:"password"` + DB int `yaml:"db"` + MasterName string `yaml:"master_name"` MaxGetMultiConcurrency int `yaml:"max_get_multi_concurrency"` GetMultiBatchSize int `yaml:"get_multi_batch_size"` @@ -45,7 +46,7 @@ type RedisClientConfig struct { func (cfg *RedisClientConfig) RegisterFlagsWithPrefix(f *flag.FlagSet, prefix string) { f.StringVar(&cfg.Addresses, prefix+"addresses", "", "Comma separated list of redis addresses. Supported prefixes are: dns+ (looked up as an A/AAAA query), dnssrv+ (looked up as a SRV query, dnssrvnoa+ (looked up as a SRV query, with no A/AAAA lookup made after that).") f.StringVar(&cfg.Username, prefix+"username", "", "Redis username.") - f.StringVar(&cfg.Password, prefix+"password", "", "Redis password.") + f.Var(&cfg.Password, prefix+"password", "Redis password.") f.IntVar(&cfg.DB, prefix+"db", 0, "Database to be selected after connecting to the server.") f.DurationVar(&cfg.DialTimeout, prefix+"dial-timeout", time.Second*5, "Client dial timeout.") f.DurationVar(&cfg.ReadTimeout, prefix+"read-timeout", time.Second*3, "Client read timeout.") @@ -82,7 +83,7 @@ func (cfg *RedisClientConfig) ToRedisClientConfig() cacheutil.RedisClientConfig return cacheutil.RedisClientConfig{ Addr: cfg.Addresses, Username: cfg.Username, - Password: cfg.Password, + Password: cfg.Password.Value, DB: cfg.DB, MasterName: cfg.MasterName, DialTimeout: cfg.DialTimeout, diff --git a/pkg/util/http.go b/pkg/util/http.go index da7c40cc4db..a1a221b2365 100644 --- a/pkg/util/http.go +++ b/pkg/util/http.go @@ -20,6 +20,8 @@ import ( otlog "github.com/opentracing/opentracing-go/log" yaml "gopkg.in/yaml.v2" yamlv3 "gopkg.in/yaml.v3" + + "github.com/cortexproject/cortex/pkg/util/flagext" ) const messageSizeLargerErrFmt = "received message larger than max (%d vs %d)" @@ -31,18 +33,18 @@ func IsRequestBodyTooLarge(err error) bool { // BasicAuth configures basic authentication for HTTP clients. type BasicAuth struct { - Username string `yaml:"basic_auth_username"` - Password string `yaml:"basic_auth_password"` + Username string `yaml:"basic_auth_username"` + Password flagext.Secret `yaml:"basic_auth_password"` } func (b *BasicAuth) RegisterFlagsWithPrefix(prefix string, f *flag.FlagSet) { f.StringVar(&b.Username, prefix+"basic-auth-username", "", "HTTP Basic authentication username. It overrides the username set in the URL (if any).") - f.StringVar(&b.Password, prefix+"basic-auth-password", "", "HTTP Basic authentication password. It overrides the password set in the URL (if any).") + f.Var(&b.Password, prefix+"basic-auth-password", "HTTP Basic authentication password. It overrides the password set in the URL (if any).") } // IsEnabled returns false if basic authentication isn't enabled. func (b BasicAuth) IsEnabled() bool { - return b.Username != "" || b.Password != "" + return b.Username != "" || b.Password.Value != "" } // WriteJSONResponse writes some JSON as a HTTP response.