2026-10-18 20:06:05 +02:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
2026-10-24 09:35:45 +02:00
|
|
|
"strings"
|
2026-10-18 20:06:05 +02:00
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-18 20:06:05 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 20:06:05 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// CORS response headers (names in one place so preflight and the real POST
|
|
|
|
|
// set the same ones).
|
2026-10-18 20:06:05 +02:00
|
|
|
const (
|
|
|
|
|
headerAllowOrigin = "Access-Control-Allow-Origin"
|
|
|
|
|
headerAllowMethods = "Access-Control-Allow-Methods"
|
|
|
|
|
headerAllowHeaders = "Access-Control-Allow-Headers"
|
|
|
|
|
headerMaxAge = "Access-Control-Max-Age"
|
|
|
|
|
headerVary = "Vary"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// preflightAge is the preflight cache duration: 10 minutes cover a chat
|
|
|
|
|
// session and keep the OPTIONS chatter small.
|
2026-10-18 20:06:05 +02:00
|
|
|
const preflightAge = "600"
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 20:06:05 +02:00
|
|
|
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 ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 20:06:05 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 20:06:05 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-10-24 09:35:45 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-24 09:35:45 +02:00
|
|
|
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,
|
2026-10-24 11:15:35 +02:00
|
|
|
"origin "+origin+" is not allowed (browsers may only POST /speak from origins listed in server.allowed_origins)")
|
2026-10-24 09:35:45 +02:00
|
|
|
})
|
|
|
|
|
}
|