package test // Prozessgrenzen-Tests für die CORS-Tür (Task 06): OPTIONS-Preflight und // echter POST auf /speak mit Headern aus der Config-Liste // TALK_ALLOWED_ORIGINS — beobachtet als Black Box über rohe HTTP-Requests. // Bindend: exakte Origins; "*" nur explizit; leere Liste = gar keine // CORS-Header; CORS nur auf /speak. Die Tür ist SERVERSEITIG zu: Jede // Anfrage mit fremdem Origin-Header außerhalb des erlaubten Wegs (OPTIONS // /speak, POST /speak von gelisteter Origin) wird mit 403 abgelehnt, bevor // sie Wirkung hat — CORS allein verhindert nur das Lesen der Antwort. import ( "net/http" "testing" "time" ) const azOrigin = "https://chat.az-gruppe.com" // TestCORSPraeflightGelisted (Abnahmekriterium): Preflight von gelisteter // Origin → 204 mit Freigabe-Headern; ungeliste Origin → 204 OHNE // Access-Control-Allow-Origin. func TestCORSPraeflightGelisted(t *testing.T) { root := t.TempDir() fake := newFakeProvider(t, 0) d := startDaemon(t, root, fake.srv.URL, map[string]string{ "TALK_ALLOWED_ORIGINS": azOrigin, }) raw := preflight(t, d, azOrigin) if raw.status != http.StatusNoContent { t.Fatalf("Preflight-Status = %d, want 204", raw.status) } if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin { t.Errorf("ACAO = %q, want %q", got, azOrigin) } if methods := raw.header.Get("Access-Control-Allow-Methods"); methods != "POST, OPTIONS" { t.Errorf("Access-Control-Allow-Methods = %q, want %q", methods, "POST, OPTIONS") } if headers := raw.header.Get("Access-Control-Allow-Headers"); headers != "Content-Type" { t.Errorf("Access-Control-Allow-Headers = %q, want %q", headers, "Content-Type") } if vary := raw.header.Get("Vary"); vary != "Origin" { t.Errorf("Vary = %q, want Origin (Cache-Korrektheit)", vary) } // Ungelistete Origin: sauberer 204, aber bewusst ohne CORS-Header — // der Browser verwirft den geplanten POST, der Daemon bleibt erreichbar. raw = preflight(t, d, "https://evil.example") if raw.status != http.StatusNoContent { t.Fatalf("Preflight (ungelisted) Status = %d, want 204", raw.status) } if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("ACAO für ungeliste Origin = %q, want leer", got) } } // TestCORSPostGelisted: Auch der echte (non-preflight) POST trägt ACAO, // wenn die Anfrage-Origin gelistet ist — sonst nicht. func TestCORSPostGelisted(t *testing.T) { root := t.TempDir() fake := newFakeProvider(t, 0) d := startDaemon(t, root, fake.srv.URL, map[string]string{ "TALK_ALLOWED_ORIGINS": azOrigin, }) raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{ "Origin": azOrigin, "Content-Type": "application/json", }, `{"text":"browser sagt hallo"}`) if raw.status != http.StatusAccepted { t.Fatalf("POST /speak Status = %d, want 202", raw.status) } if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin { t.Errorf("ACAO auf POST = %q, want %q", got, azOrigin) } fake.waitCompleted(t, 1) // Ungelistete Origin: 403 OHNE Wirkung — der Fake-Provider darf keinen // zweiten Treffer sehen. (Früher 202 ohne CORS-Header: der Browser // konnte die Antwort nicht lesen, gesprochen wurde trotzdem.) raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{ "Origin": "https://evil.example", "Content-Type": "application/json", }, `{"text":"böser browser"}`) if raw.status != http.StatusForbidden { t.Fatalf("POST /speak (ungelisted) Status = %d, want 403", raw.status) } if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("ACAO für ungeliste Origin = %q, want leer", got) } fake.assertNoFurtherArrivals(t, 300*time.Millisecond) } // TestCORSLeereListe: Ohne allowed_origins (Default) sendet der Daemon // GAR KEINE CORS-Header — Tür zu, der Browser blockt. func TestCORSLeereListe(t *testing.T) { root := t.TempDir() fake := newFakeProvider(t, 0) d := startDaemon(t, root, fake.srv.URL, nil) // keine Origins konfiguriert raw := preflight(t, d, azOrigin) if raw.status != http.StatusNoContent { t.Fatalf("Preflight-Status = %d, want 204", raw.status) } for _, h := range []string{ "Access-Control-Allow-Origin", "Access-Control-Allow-Methods", "Access-Control-Allow-Headers", } { if got := raw.header.Get(h); got != "" { t.Errorf("%s = %q bei leerer Liste, want nicht gesetzt", h, got) } } raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{ "Origin": azOrigin, "Content-Type": "application/json", }, `{"text":"ohne cors"}`) if raw.status != http.StatusForbidden { t.Fatalf("POST /speak mit Origin bei leerer Liste: Status = %d, want 403", raw.status) } fake.assertNoHits(t, 300*time.Millisecond) // Ohne Origin-Header (CLI, curl) bleibt derselbe POST erlaubt. raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{ "Content-Type": "application/json", }, `{"text":"ohne origin"}`) if raw.status != http.StatusAccepted { t.Fatalf("POST /speak ohne Origin: Status = %d, want 202", raw.status) } fake.waitCompleted(t, 1) } // TestCORSWildcard: Der Eintrag "*" erlaubt jede Origin ausdrücklich — // ACAO ist dann das literale "*". func TestCORSWildcard(t *testing.T) { root := t.TempDir() fake := newFakeProvider(t, 0) d := startDaemon(t, root, fake.srv.URL, map[string]string{ "TALK_ALLOWED_ORIGINS": "*", }) for _, origin := range []string{"https://irgendwo.example", "http://localhost:5173"} { raw := preflight(t, d, origin) if raw.status != http.StatusNoContent { t.Fatalf("Preflight von %s: Status = %d, want 204", origin, raw.status) } if got := raw.header.Get("Access-Control-Allow-Origin"); got != "*" { t.Errorf("ACAO von %s = %q, want *", origin, got) } } } // TestCORSNurAufSpeak: Kein anderer Endpunkt ist aus dem Browser erreichbar // — auch nicht von einer gelisteten Origin: OPTIONS und GET auf /status mit // Origin-Header sind 403 ohne CORS-Header. func TestCORSNurAufSpeak(t *testing.T) { root := t.TempDir() fake := newFakeProvider(t, 0) d := startDaemon(t, root, fake.srv.URL, map[string]string{ "TALK_ALLOWED_ORIGINS": azOrigin, }) raw := doRequest(t, http.MethodOptions, d.baseURL+"/status", map[string]string{ "Origin": azOrigin, "Access-Control-Request-Method": http.MethodGet, }, "") if raw.status != http.StatusForbidden { t.Fatalf("OPTIONS /status Status = %d, want 403 (kein Browser-Zugriff)", raw.status) } if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("OPTIONS /status ACAO = %q, want leer", got) } raw = doRequest(t, http.MethodGet, d.baseURL+"/status", map[string]string{ "Origin": azOrigin, }, "") if raw.status != http.StatusForbidden { t.Fatalf("GET /status mit Origin: Status = %d, want 403", raw.status) } if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("GET /status ACAO = %q, want leer (CORS nur auf /speak)", got) } } // TestBrowserKannNichtSteuern: Der eigentliche Angriffsfall. Eine beliebige // Webseite kann ohne Preflight „simple requests" absetzen (POST mit // text/plain-Body, Formular-POST ohne Body) — der Browser blockt nur das // Lesen der Antwort, nicht das Senden. talkd darf darauf weder sprechen noch // muten noch abbrechen noch das MCP-Tool ausführen: alles 403, Zustand // unverändert. func TestBrowserKannNichtSteuern(t *testing.T) { root := t.TempDir() fake := newFakeProvider(t, 0) d := startDaemon(t, root, fake.srv.URL, map[string]string{ "TALK_ALLOWED_ORIGINS": azOrigin, // Tür für EINE Origin offen — andere trotzdem zu }) evil := "https://evil.example" // Simple request: POST mit text/plain-Body, kein Preflight. raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{ "Origin": evil, "Content-Type": "text/plain", }, `{"text":"gekapert","now":true}`) if raw.status != http.StatusForbidden { t.Fatalf("simple POST /speak von %s: Status = %d, want 403", evil, raw.status) } // Steuer-Endpunkte brauchen keinen Body — ein Formular-POST reicht. for _, path := range []string{"/mute", "/unmute", "/cancel"} { raw = doRequest(t, http.MethodPost, d.baseURL+path, map[string]string{"Origin": evil}, "") if raw.status != http.StatusForbidden { t.Errorf("POST %s von %s: Status = %d, want 403", path, evil, raw.status) } } // Auch die gelistete Origin darf NICHT steuern — nur sprechen. raw = doRequest(t, http.MethodPost, d.baseURL+"/mute", map[string]string{"Origin": azOrigin}, "") if raw.status != http.StatusForbidden { t.Errorf("POST /mute von gelisteter Origin: Status = %d, want 403 (CORS nur auf /speak)", raw.status) } // MCP aus dem Browser: ebenfalls zu. raw = doRequest(t, http.MethodPost, d.baseURL+"/mcp", map[string]string{ "Origin": evil, "Content-Type": "text/plain", }, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"speak","arguments":{"text":"gekapert"}}}`) if raw.status != http.StatusForbidden { t.Errorf("POST /mcp von %s: Status = %d, want 403", evil, raw.status) } // Nichts davon hatte Wirkung: kein Treffer beim Provider, nicht gemutet. fake.assertNoHits(t, 300*time.Millisecond) if st := getStatus(t, d); st.Muted { t.Errorf("Daemon ist gemutet — Browser-POST /mute hat gewirkt") } } // TestStatusCorsFeld: GET /status trägt die CORS-Origins als cors-Feld — // gelistet als [], wenn die Tür zu ist (Compat-Anforderung Task 06). func TestStatusCorsFeld(t *testing.T) { t.Run("gelistet", func(t *testing.T) { root := t.TempDir() fake := newFakeProvider(t, 0) d := startDaemon(t, root, fake.srv.URL, map[string]string{ "TALK_ALLOWED_ORIGINS": azOrigin + ", https://grid.ag", }) st := getStatus(t, d) if len(st.Cors) != 2 || st.Cors[0] != azOrigin || st.Cors[1] != "https://grid.ag" { t.Errorf("cors = %v, want beide gelisteten Origins", st.Cors) } }) t.Run("leer = [] (nicht null)", func(t *testing.T) { root := t.TempDir() fake := newFakeProvider(t, 0) d := startDaemon(t, root, fake.srv.URL, nil) st := getStatus(t, d) if st.Cors == nil || len(st.Cors) != 0 { t.Errorf("cors = %v, want [] bei geschlossener Tür", st.Cors) } }) }