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>
112 lines
4.5 KiB
Go
112 lines
4.5 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// CORS-Tür für POST /speak (Task 06) — und NUR für diesen Endpunkt: Ein
|
|
// Browser (z. B. eine spätere LibreChat-Integration) darf genau die Ansage-
|
|
// Schnittstelle cross-origin rufen, keine Steuer-Endpunkte. Die Erlaubnis
|
|
// kommt ausschließlich aus der Config-Liste server.allowed_origins
|
|
// (Env TALK_ALLOWED_ORIGINS): exakte Origins wie
|
|
// "https://chat.az-gruppe.com"; der Eintrag "*" erlaubt jede Origin
|
|
// ausdrücklich. Bewusster Default: LEER — dann sendet der Daemon gar keine
|
|
// CORS-Header und der Browser blockt (Tür zu, bis sie konfiguriert wird).
|
|
//
|
|
// 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).
|
|
const (
|
|
headerAllowOrigin = "Access-Control-Allow-Origin"
|
|
headerAllowMethods = "Access-Control-Allow-Methods"
|
|
headerAllowHeaders = "Access-Control-Allow-Headers"
|
|
headerMaxAge = "Access-Control-Max-Age"
|
|
headerVary = "Vary"
|
|
)
|
|
|
|
// preflightAge ist die Preflight-Cache-Dauer: 10 Minuten reichen für eine
|
|
// Chat-Session und halten den OPTIONS-Chatter klein.
|
|
const preflightAge = "600"
|
|
|
|
// corsACAOLiefert den Wert für Access-Control-Allow-Origin: bei Wildcard-
|
|
// Konfiguration das literale "*", sonst die exakt gematchte Origin (die
|
|
// identisch mit dem Config-Eintrag ist). Leer heißt: nicht erlaubt.
|
|
func (h *handler) corsACAO(r *http.Request) string {
|
|
origin := r.Header.Get("Origin")
|
|
if origin == "" {
|
|
return ""
|
|
}
|
|
for _, allowed := range h.allowedOrigins {
|
|
if allowed == "*" {
|
|
return "*"
|
|
}
|
|
if allowed == origin {
|
|
return allowed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// setSpeakCors setzt die CORS-Header auf echten POST-/speak-Antworten,
|
|
// wenn die Anfrage-Origin erlaubt ist. Vary: Origin steht immer (auch bei
|
|
// geschlossener Tür), damit Caches die Antwort nie fälschlich origin-
|
|
// übergreifend wiederverwenden.
|
|
func (h *handler) setSpeakCors(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Add(headerVary, "Origin")
|
|
if acao := h.corsACAO(r); acao != "" {
|
|
w.Header().Set(headerAllowOrigin, acao)
|
|
}
|
|
}
|
|
|
|
// speakPreflight behandelt OPTIONS /speak (CORS-Preflight): 204 mit den
|
|
// Freigabe-Headern bei gelisteter Origin, 204 ohne jegliche CORS-Header
|
|
// sonst (ungelistet, fehlend oder Tür komplett zu) — der Browser verwirft
|
|
// den eigentlichen Request, während der Daemon für Nicht-Browser-Clients
|
|
// erreichbar bleibt.
|
|
func (h *handler) speakPreflight(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Add(headerVary, "Origin")
|
|
if acao := h.corsACAO(r); acao != "" {
|
|
w.Header().Set(headerAllowOrigin, acao)
|
|
w.Header().Set(headerAllowMethods, "POST, OPTIONS")
|
|
w.Header().Set(headerAllowHeaders, "Content-Type")
|
|
w.Header().Set(headerMaxAge, preflightAge)
|
|
}
|
|
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)")
|
|
})
|
|
}
|