feat(config): CORS-Erlaubnisliste server.allowed_origins (TALK_ALLOWED_ORIGINS)

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.
This commit is contained in:
m3ta-chiron committed 2026-10-18 20:06:05 +02:00
1 parent 24ceff3f48
commit cef6e6b9f2
2 files changed
+147

No files matched your search

+44
View File
@@ -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
}
+103
View File
@@ -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.