types: reject non-boolean values for boolean settings

This commit is contained in:
Kristoffer Dalby committed 2026-11-02 19:15:40 +01:00
1 parent 14baf55421
commit f2ecf949a7
2 files changed
+208

No files matched your search

+53
View File
@@ -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)
+155
View File
@@ -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)
})
}
}