package api import ( "net/http" "strings" ) // CORS door for POST /speak, and ONLY for this endpoint: a browser (e.g. a // later LibreChat integration) may call exactly the announcement interface // cross-origin, never the control endpoints. Permission comes exclusively // from the config list server.allowed_origins (env TALK_ALLOWED_ORIGINS): // exact origins such as "https://chat.example.com"; the entry "*" explicitly // allows any origin. Deliberate default: EMPTY. The daemon then sends no // CORS headers at all and the browser blocks (door closed until configured). // // The door is closed server-side, not only browser-side (originGuard): CORS // alone only prevents READING the response, not the effect. A browser sends // a POST with a text/plain body or a form without preflight, and a server // that still reads the body as JSON would have spoken, muted or cancelled. // Therefore talkd rejects every request with a foreign Origin header (403) // that is not the permitted path via /speak. Only the preflight OPTIONS // /speak stays a 204 without CORS headers for unlisted origins (it has no // effect; the browser drops the planned POST). CLI, curl and other // non-browser clients send no Origin header and are unaffected. // CORS response headers (names in one place so preflight and the real POST // set the same ones). const ( headerAllowOrigin = "Access-Control-Allow-Origin" headerAllowMethods = "Access-Control-Allow-Methods" headerAllowHeaders = "Access-Control-Allow-Headers" headerMaxAge = "Access-Control-Max-Age" headerVary = "Vary" ) // preflightAge is the preflight cache duration: 10 minutes cover a chat // session and keep the OPTIONS chatter small. const preflightAge = "600" // corsACAO returns the value for Access-Control-Allow-Origin: the literal // "*" for a wildcard configuration, otherwise the exactly matched origin // (identical to the config entry). Empty means: not allowed. 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 sets the CORS headers on real POST /speak responses when the // request origin is allowed. Vary: Origin is always present (even with the // door closed) so caches never wrongly reuse the response across origins. 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 handles OPTIONS /speak (CORS preflight): 204 with the // permission headers for a listed origin, 204 without any CORS headers // otherwise (unlisted, missing or door completely closed). The browser // drops the actual request while the daemon stays reachable for // non-browser clients. 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 is the middleware in front of the router: requests with an // Origin header (i.e. from a browser) only pass as OPTIONS /speak (preflight, // no effect) or as POST /speak from a listed origin. Everything else // (unlisted origin on /speak, any browser access to control endpoints and // /mcp) is rejected with 403 before a body is read or an effect is // triggered. Without an Origin header (CLI, curl, MCP clients) nothing // happens. 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+" is not allowed (browsers may only POST /speak from origins listed in server.allowed_origins)") }) }