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

+36 -3
View File
@@ -2,6 +2,7 @@ package api
import (
"net/http"
"strings"
)
// CORS-Tür für POST /speak (Task 06) — und NUR für diesen Endpunkt: Ein
@@ -13,9 +14,16 @@ import (
// ausdrücklich. Bewusster Default: LEER — dann sendet der Daemon gar keine
// CORS-Header und der Browser blockt (Tür zu, bis sie konfiguriert wird).
//
// Ungelistete Origins bekommen bewusst trotzdem 204/202 ohne CORS-Header:
// HTTP-seitig ist die Anfrage sauber, die Blockade macht der Browser (so
// verrät der Daemon nichts über seine Erlaubnisliste).
// Die Tür ist serverseitig zu, nicht nur browserseitig (originGuard): CORS
// allein verhindert nur das LESEN der Antwort, nicht die Wirkung — ein
// Browser schickt einen POST mit text/plain-Body oder ein Formular ohne
// Preflight ab, und ein Server, der den Body dennoch als JSON liest, hätte
// gesprochen, gemutet oder abgebrochen. Deshalb lehnt talkd jede Anfrage
// mit fremdem Origin-Header ab (403), die nicht der erlaubte Weg über
// /speak ist. Nur der Preflight OPTIONS /speak bleibt für ungelistete
// Origins ein 204 ohne CORS-Header (er hat keine Wirkung; der Browser
// verwirft den geplanten POST). CLI, curl und andere Nicht-Browser-Clients
// senden keinen Origin-Header und sind nicht betroffen.
// CORS-Antwort-Header (Namen zentral, damit Preflight und echter POST
// dieselben setzen).
@@ -76,3 +84,28 @@ func (h *handler) speakPreflight(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusNoContent)
}
// originGuard ist die Middleware vor dem Router: Anfragen mit Origin-Header
// (also aus einem Browser) kommen nur als OPTIONS /speak (Preflight, ohne
// Wirkung) oder als POST /speak von gelisteter Origin durch. Alles andere —
// ungelistete Origin auf /speak, jeder Browser-Zugriff auf Steuer-Endpunkte
// und /mcp — wird mit 403 abgelehnt, bevor ein Body gelesen oder eine
// Wirkung ausgelöst wird. Ohne Origin-Header (CLI, curl, MCP-Clients)
// passiert nichts.
func (h *handler) originGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
isSpeak := strings.TrimRight(r.URL.Path, "/") == "/speak"
if isSpeak && (r.Method == http.MethodOptions || (r.Method == http.MethodPost && h.corsACAO(r) != "")) {
next.ServeHTTP(w, r)
return
}
w.Header().Add(headerVary, "Origin")
writeError(w, http.StatusForbidden,
"Origin "+origin+" ist nicht erlaubt (Browser-Zugriff nur auf POST /speak von gelisteten Origins in server.allowed_origins)")
})
}