Files
talk/internal/api/cors.go
T

111 lines
4.5 KiB
Go
Raw Normal View History

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)")
})
}