From d40350daf13f892961230f77a6d123891e702213 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 17 Oct 2026 10:56:02 +0000 Subject: [PATCH] types: reject non-boolean values for boolean settings --- hscontrol/types/config.go | 53 +++++++++++ hscontrol/types/config_test.go | 155 +++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 32651f6f0..107d43291 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -715,10 +715,55 @@ func validateServerConfig() error { return v.Err() } +var boolConfigKeys = []string{ + "auto_update.enabled", + "cli.insecure", + "database.debug", + "database.gorm.parameterized_queries", + "database.gorm.skip_err_record_not_found", + "database.sqlite.write_ahead_log", + "derp.auto_update_enabled", + "derp.server.automatically_add_embedded_derp_region", + "derp.server.enabled", + "derp.server.verify_clients", + "disable_check_updates", + "dns.certificates.enabled", + "dns.magic_dns", + "dns.override_local_dns", + "logtail.enabled", + "oidc.email_verified_required", + "oidc.only_start_if_oidc_is_available", + "oidc.pkce.enabled", + "oidc.use_expiry_from_token", + "taildrop.enabled", +} + +// Validate raw values before GetBool can discard conversion errors. +func validateBoolConfig(v *configValidator, keys ...string) { + for _, key := range keys { + value := viper.Get(key) + if value == nil { + continue + } + + _, err := strconv.ParseBool(fmt.Sprint(value)) + if err != nil { + v.Add(&ConfigError{ + Reason: key + " is not a boolean", + Current: []KV{{key, value}}, + Hint: "use true or false", + Cause: err, + }) + } + } +} + // validateServerConfigInto runs every viper-level validation rule // against the provided validator. Caller owns the validator and is // responsible for inspecting v.Err() once all sub-builders have run. func validateServerConfigInto(v *configValidator) { + validateBoolConfig(v, boolConfigKeys...) + depr := deprecator{seen: make(set.Set[string])} // Register aliases for backward compatibility @@ -1468,6 +1513,14 @@ func trustedProxies() ([]netip.Prefix, error) { // LoadCLIConfig returns the needed configuration for the CLI client // of Headscale to connect to a Headscale server. func LoadCLIConfig() (*Config, error) { + v := &configValidator{} + validateBoolConfig(v, "cli.insecure", "disable_check_updates") + + err := v.Err() + if err != nil { + return nil, err + } + logConfig := logConfig() zerolog.SetGlobalLevel(logConfig.Level) diff --git a/hscontrol/types/config_test.go b/hscontrol/types/config_test.go index 507649f69..714ed1a6d 100644 --- a/hscontrol/types/config_test.go +++ b/hscontrol/types/config_test.go @@ -4,11 +4,15 @@ import ( "bytes" "encoding/json" "fmt" + "go/ast" + "go/parser" + "go/token" "io/fs" "net/netip" "net/url" "os" "path/filepath" + "strconv" "strings" "testing" @@ -1672,3 +1676,154 @@ func TestServesRoute(t *testing.T) { narrow := netip.MustParsePrefix("100.64.0.0/24") assert.True(t, (&Config{PrefixV4: &narrow}).ServesRoute(netip.MustParsePrefix("100.100.0.1/32"))) } + +func TestBoolConfigValidation(t *testing.T) { + for _, source := range []string{"file", "env"} { + for _, tt := range []struct { + value string + want bool + invalid bool + }{ + {"true", true, false}, + {"false", false, false}, + {"1", true, false}, + {"0", false, false}, + {"T", true, false}, + {"f", false, false}, + {"TRUE", true, false}, + {"False", false, false}, + {"True", true, false}, + {"FALSE", false, false}, + {"", true, false}, + {"null", true, false}, + {"yes", false, true}, + {"on", false, true}, + {"ture", false, true}, + {"tRuE", false, true}, + {"2", false, true}, + {"[true]", false, true}, + {`""`, false, true}, + } { + if source == "env" && tt.value == "null" { + continue + } + + t.Run(source+"/"+tt.value, func(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + t.Setenv("HEADSCALE_OIDC_EMAIL_VERIFIED_REQUIRED", "") + + config := `server_url: https://headscale.example.com +noise: + private_key_path: noise_private.key +prefixes: + v4: 100.64.0.0/10 +database: + type: sqlite3 +dns: + magic_dns: false + override_local_dns: false +` + if source == "file" && tt.value != "" { + config += "oidc:\n email_verified_required: " + tt.value + "\n" + } + + if source == "env" { + t.Setenv("HEADSCALE_OIDC_EMAIL_VERIFIED_REQUIRED", tt.value) + } + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(config), 0o600)) + require.NoError(t, LoadConfig(path, true)) + + loaded, err := LoadServerConfig() + if tt.invalid { + require.Error(t, err) + assert.Contains(t, err.Error(), "oidc.email_verified_required") + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, loaded.OIDC.EmailVerifiedRequired) + }) + } + } +} + +func TestBoolConfigKeysComplete(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "config.go", nil, 0) + require.NoError(t, err) + + keys := make(map[string]bool) + + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "GetBool" { + return true + } + + object, ok := selector.X.(*ast.Ident) + if !ok || object.Name != "viper" { + return true + } + + require.Len(t, call.Args, 1) + literal, ok := call.Args[0].(*ast.BasicLit) + require.True(t, ok, "boolean config keys must be string literals") + + key, err := strconv.Unquote(literal.Value) + require.NoError(t, err) + + keys[key] = true + + return true + }) + require.NotEmpty(t, keys) + + for _, key := range boolConfigKeys { + assert.Contains(t, keys, key, "unused or repeated boolean config key") + delete(keys, key) + } + + assert.Empty(t, keys, "GetBool keys missing from boolean validation") +} + +func TestExampleConfigBooleanSettings(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + require.NoError(t, LoadConfig("../../config-example.yaml", true)) + + _, err := LoadServerConfig() + require.NoError(t, err) +} + +func TestLoadCLIConfigRejectsNonBoolean(t *testing.T) { + for _, key := range []string{"cli.insecure", "disable_check_updates"} { + t.Run(key, func(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, nil, 0o600)) + require.NoError(t, LoadConfig(path, true)) + + env := "HEADSCALE_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_")) + t.Setenv(env, "true") + + _, err := LoadCLIConfig() + require.NoError(t, err) + + t.Setenv(env, "ture") + + _, err = LoadCLIConfig() + require.Error(t, err) + assert.Contains(t, err.Error(), key) + }) + } +}