fix(api): Origin-Guard — Browser erreichen nur POST /speak von gelisteten Origins

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>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 09:35:45 +02:00
1 parent 8909f37802
commit 46bd507fd8
4 files changed
+132 -20

No files matched your search

+81 -13
View File
@@ -4,11 +4,15 @@ package test
// 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 (der Browser blockt); CORS nur auf /speak.
// 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"
@@ -72,16 +76,20 @@ func TestCORSPostGelisted(t *testing.T) {
}
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.StatusAccepted {
t.Fatalf("POST /speak (ungelisted) Status = %d, want 202 (HTTP bleibt sauber)", raw.status)
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
@@ -109,12 +117,19 @@ func TestCORSLeereListe(t *testing.T) {
"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 Status = %d, want 202", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO auf POST bei leerer Liste = %q, want leer", got)
t.Fatalf("POST /speak ohne Origin: Status = %d, want 202", raw.status)
}
fake.waitCompleted(t, 1)
}
// TestCORSWildcard: Der Eintrag "*" erlaubt jede Origin ausdrücklich —
@@ -137,8 +152,9 @@ func TestCORSWildcard(t *testing.T) {
}
}
// TestCORSNurAufSpeak: Kein anderer Endpunkt trägt CORS-Header — OPTIONS
// auf /status ist 405 (kein Preflight-Support), GET /status ohne ACAO.
// 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)
@@ -150,8 +166,8 @@ func TestCORSNurAufSpeak(t *testing.T) {
"Origin": azOrigin,
"Access-Control-Request-Method": http.MethodGet,
}, "")
if raw.status != http.StatusMethodNotAllowed {
t.Fatalf("OPTIONS /status Status = %d, want 405 (kein CORS-Support)", raw.status)
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)
@@ -160,14 +176,66 @@ func TestCORSNurAufSpeak(t *testing.T) {
raw = doRequest(t, http.MethodGet, d.baseURL+"/status", map[string]string{
"Origin": azOrigin,
}, "")
if raw.status != http.StatusOK {
t.Fatalf("GET /status Status = %d, want 200", raw.status)
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) {