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:
1 parent
8909f37802
commit
46bd507fd8
4 files changed
+132
-20
No files matched your search
+36
-3
@@ -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)")
|
||||
})
|
||||
}
|
||||
@@ -41,7 +41,9 @@ type HandlerConfig struct {
|
||||
}
|
||||
|
||||
// NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per
|
||||
// ServeMux-Muster: falsche Methode → 405, unbekannter Pfad → 404.
|
||||
// ServeMux-Muster: falsche Methode → 405, unbekannter Pfad → 404. Vor dem
|
||||
// Router steht der originGuard (cors.go): Browser-Anfragen (Origin-Header)
|
||||
// erreichen nur POST /speak von gelisteten Origins; alles andere ist 403.
|
||||
func NewHandler(cfg HandlerConfig) http.Handler {
|
||||
h := &handler{
|
||||
queue: cfg.Queue,
|
||||
@@ -63,7 +65,7 @@ func NewHandler(cfg HandlerConfig) http.Handler {
|
||||
mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
|
||||
mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06)
|
||||
mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams
|
||||
return mux
|
||||
return h.originGuard(mux)
|
||||
}
|
||||
|
||||
// handler ist der interne Zustand der HTTP-Oberfläche.
|
||||
|
||||
Reference in new issue
Block a user