Files

108 lines
4.3 KiB
Go
Raw Permalink Normal View History

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