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