From cef6e6b9f2e6811633fdd9dbf88800bbf80341a5 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sun, 18 Oct 2026 20:06:05 +0200 Subject: [PATCH] feat(config): CORS-Erlaubnisliste server.allowed_origins (TALK_ALLOWED_ORIGINS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exakte Origins wie https://chat.az-gruppe.com; Wildcard "*" nur explizit. Bewusster Default: leer = keine CORS-Header (Tür zu). Env schlägt Datei (kommagetrennt; "off" leert), leere Einträge sind klare Fehler. --- internal/config/config.go | 44 ++++++++++++++ internal/config/config_test.go | 103 +++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index e661bd7..325abf1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "time" "github.com/BurntSushi/toml" @@ -85,6 +86,14 @@ type Server struct { // Port des Daemon-HTTP-Servers auf 127.0.0.1 — config // `[server] port = 7800`, Env-Override TALK_PORT, Default 7800. Port int `toml:"port"` + + // AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (Task 06): + // exakte Origins wie "https://chat.az-gruppe.com". Der Eintrag "*" + // erlaubt jede Origin ausdrücklich. Bewusster Default: LEER — ohne + // Einträge sendet der Daemon gar keine CORS-Header und der Browser + // blockt Cross-Origin-Anfragen; die Tür ist zu, bis sie konfiguriert + // wird (Env-Override TALK_ALLOWED_ORIGINS, kommagetrennt; "off" leert). + AllowedOrigins []string `toml:"allowed_origins"` } // Queue sind die Rate-Limit-Gesetze des Daemons (TOML `[queue]`). @@ -178,6 +187,17 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) { } cfg.Server.Port = port } + if v := getenv("TALK_ALLOWED_ORIGINS"); v != "" { + if v == "off" { + cfg.Server.AllowedOrigins = nil + } else { + origins, err := splitOrigins(v) + if err != nil { + return cfg, fmt.Errorf("TALK_ALLOWED_ORIGINS: %w", err) + } + cfg.Server.AllowedOrigins = origins + } + } if v := getenv("TALK_RATE_LIMIT"); v != "" { d, err := time.ParseDuration(v) if err != nil || d < 0 { @@ -215,6 +235,14 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) { return cfg, fmt.Errorf("audio.sink: ungültiger Wert %q (erlaubt: %q, %q)", cfg.Audio.Sink, SinkOto, SinkNull) } + // allowed_origins aus der Datei prüfen (Env wurde oben geparst): Eine + // leere Liste ist gültig (Tür zu), einzelne Einträge müssen aber + // substanziell sein — klarer Fehler statt stiller Ignoranz. + for _, o := range cfg.Server.AllowedOrigins { + if strings.TrimSpace(o) == "" { + return cfg, fmt.Errorf("server.allowed_origins: leerer Eintrag in %v", cfg.Server.AllowedOrigins) + } + } // quiet_hours aus der Datei validieren (Env wurde oben geprüft) und // quiet_mode leer (Datei ohne Wert) auf den Default heben. if cfg.QuietHours != "" { @@ -227,3 +255,19 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) { } return cfg, nil } + +// splitOrigins zerlegt eine kommagetrennte Origin-Liste (Env-Form von +// server.allowed_origins, z. B. "https://a.example, https://b.example"). +// Whitespace um Einträge toleriert, leere Einträge sind ein klarer Fehler. +func splitOrigins(v string) ([]string, error) { + parts := strings.Split(v, ",") + origins := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + return nil, fmt.Errorf("leerer Eintrag in %q (komma-getrennte Origins oder \"off\")", v) + } + origins = append(origins, p) + } + return origins, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bc58add..86efe94 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -356,6 +356,109 @@ func TestLoadServerPort(t *testing.T) { }) } +func TestLoadAllowedOrigins(t *testing.T) { + writeTOML := func(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path + } + + t.Run("Default: leere Liste (kein CORS)", func(t *testing.T) { + cfg, err := Load(Options{ + File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), + Getenv: env(nil), + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.Server.AllowedOrigins) != 0 { + t.Errorf("AllowedOrigins = %v, want leer (Tür zu)", cfg.Server.AllowedOrigins) + } + }) + + t.Run("TOML-Liste", func(t *testing.T) { + cfg, err := Load(Options{ + File: writeTOML(t, "[server]\nallowed_origins = [\"https://chat.az-gruppe.com\"]\n"), + Getenv: env(nil), + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + want := []string{"https://chat.az-gruppe.com"} + if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") { + t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want) + } + }) + + t.Run("TALK_ALLOWED_ORIGINS schlägt Datei (mehrere, mit Leerzeichen)", func(t *testing.T) { + cfg, err := Load(Options{ + File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"), + Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "https://chat.az-gruppe.com, https://grid.ag"}), + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + want := []string{"https://chat.az-gruppe.com", "https://grid.ag"} + if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") { + t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want) + } + }) + + t.Run("TALK_ALLOWED_ORIGINS=off leert die Datei-Liste", func(t *testing.T) { + cfg, err := Load(Options{ + File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"), + Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "off"}), + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.Server.AllowedOrigins) != 0 { + t.Errorf("AllowedOrigins = %v, want leer (off)", cfg.Server.AllowedOrigins) + } + }) + + t.Run("Wildcard ist erlaubter Wert", func(t *testing.T) { + cfg, err := Load(Options{ + File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), + Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "*"}), + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + if strings.Join(cfg.Server.AllowedOrigins, "|") != "*" { + t.Errorf("AllowedOrigins = %v, want [*]", cfg.Server.AllowedOrigins) + } + }) + + t.Run("leere Einträge sind klare Fehler", func(t *testing.T) { + for _, tc := range []struct { + name string + file string + envv string + }{ + {"env", "", "https://a.example,,https://b.example"}, + {"datei", "[server]\nallowed_origins = [\"https://a.example\", \"\"]\n", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + file := filepath.Join(t.TempDir(), "gibt-es-nicht.toml") + if tc.file != "" { + file = writeTOML(t, tc.file) + } + _, err := Load(Options{ + File: file, + Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": tc.envv}), + }) + if err == nil || !strings.Contains(err.Error(), "allowed_origins") && !strings.Contains(err.Error(), "ALLOWED_ORIGINS") { + t.Fatalf("Fehler = %v, will klaren allowed_origins-Fehler", err) + } + }) + } + }) +} + // TestLoadGesetze deckt die Task-05-Felder ab: Rate-Limit (Default 30 s, // "0s" = unbegrenzt), High-Burst (Default an), Ruhezeiten (Default aus; // Format validiert) samt Env-Overrides und klaren Fehlern.