CORS allein verhindert nur das Lesen der Antwort, nicht die Wirkung: Ein POST mit text/plain-Body oder ein Formular-POST geht ohne Preflight raus, und talkd hat den Body trotzdem als JSON gelesen bzw. /mute, /cancel und /mcp ohne Body ausgeführt. Jede Webseite konnte so sprechen, muten und abbrechen. Neue Middleware vor dem Router: Anfragen mit Origin-Header kommen nur als OPTIONS /speak (Preflight, ohne Wirkung) oder POST /speak von gelisteter Origin durch; alles andere ist 403, bevor ein Body gelesen wird. CLI, curl und MCP-Clients senden keinen Origin und sind nicht betroffen. Prozesstests angepasst (ungelistete Origin auf /speak → 403, Steuer- Endpunkte aus dem Browser → 403) und TestBrowserKannNichtSteuern ergänzt, der den Angriff mit simple requests durchspielt und prüft, dass weder Provider noch Mute-State berührt werden. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
264 lines
10 KiB
Go
264 lines
10 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|