From 61929bb2067d511aaaf2c09b816cc2350859dbe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:48:13 +0000 Subject: [PATCH] Reject unknown YAML config keys instead of silently ignoring them loadYAMLFile used plain yaml.Unmarshal, so a typo like api_kay: (instead of api_key:) or a mis-indented block was silently dropped and the built-in default applied without warning. For a security-relevant key like api_key, that default is no authentication, which is a real footgun for anyone relying on Validate() to catch config typos at startup. Switch to yaml.NewDecoder with KnownFields(true) so an unknown key at any level now fails Load() with a descriptive error naming the offending key. Explicitly tolerate io.EOF from an empty or comment-only file, since Decode (unlike Unmarshal) treats "nothing to decode" as EOF rather than a no-op. Closes #60 --- internal/config/config.go | 15 +++++++++++--- internal/config/config_test.go | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index fa91f12..fdd1556 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,8 +4,11 @@ package config import ( + "bytes" + "errors" "flag" "fmt" + "io" "os" "time" @@ -286,14 +289,20 @@ func (t Thresholds) validate() error { // loadYAMLFile merges the YAML file at path into cfg. Only keys present in // the file override cfg's existing (default) values; absent keys are left -// untouched, since yaml.Unmarshal only writes fields it finds in the -// document. +// untouched, since the decoder only writes fields it finds in the document. +// KnownFields(true) rejects any key that doesn't map to a Config field, so a +// typo (e.g. "api_kay") fails fast at startup instead of silently falling +// back to the default (e.g. no authentication). func loadYAMLFile(path string, cfg *Config) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read %s: %w", path, err) } - if err := yaml.Unmarshal(data, cfg); err != nil { + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + // An empty or comment-only file has nothing to decode; Decode reports + // that as io.EOF, but it's not an error here since cfg is left as-is. + if err := dec.Decode(cfg); err != nil && !errors.Is(err, io.EOF) { return fmt.Errorf("parse %s: %w", path, err) } return nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a429666..939522d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -149,6 +149,44 @@ func TestLoad_MalformedYAML(t *testing.T) { } } +func TestLoad_UnknownTopLevelKeyIsError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + // "api_kay" is a typo of "api_key"; it must not be silently ignored, + // since that would leave APIKey at its default (no authentication). + writeFile(t, path, "api_kay: \"secret123\"\n") + + _, err := Load([]string{"-config", path}) + if err == nil { + t.Fatal("expected error for unknown top-level YAML key") + } +} + +func TestLoad_UnknownNestedKeyIsError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + writeFile(t, path, "thresholds:\n temperature_warn_percent: 55\n") + + _, err := Load([]string{"-config", path}) + if err == nil { + t.Fatal("expected error for unknown nested YAML key") + } +} + +func TestLoad_EmptyConfigFileIsNotError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + writeFile(t, path, "# just a comment, no keys\n") + + result, err := Load([]string{"-config", path}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !reflect.DeepEqual(result.Config, Default()) { + t.Fatalf("Load with empty/comment-only file = %+v, want defaults %+v", result.Config, Default()) + } +} + func TestLoad_VersionFlag(t *testing.T) { result, err := Load([]string{"-version"}) if err != nil {