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:
1 parent
24ceff3f48
commit
cef6e6b9f2
2 files changed
+147
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user