Files
silo-server/internal/api/handlers/ws_shared.go
QuickandClaude Opus 4.8 9123cfb1f4 fix(api): accept forwarded host in websocket origin check
The shared WebSocket upgrader rejected handshakes unless the browser's
Origin host exactly matched r.Host. Behind a TLS-terminating CDN/proxy
that rewrites Host to the internal origin (carrying the public host in
X-Forwarded-Host), this comparison always failed and every realtime
socket 403'd at the handshake — playback control, events, watch-together
rooms, and admin log streaming all share the upgrader.

checkWebSocketOrigin now also accepts an Origin matching X-Forwarded-Host,
keeping the same-origin CSRF guard intact while supporting proxied
deployments. Extract a shared forwardedHost helper (first hop of a
multi-proxy list) and reuse it from requestBaseURL, replacing the
duplicated inline parse. Also reject opaque (empty-host) origins
explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:40:55 -04:00

114 lines
2.3 KiB
Go

package handlers
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/websocket"
)
const (
wsPingInterval = 20 * time.Second
wsPongTimeout = 10 * time.Second
wsWriteTimeout = 5 * time.Second
)
var wsUpgrader = websocket.Upgrader{
CheckOrigin: checkWebSocketOrigin,
}
func checkWebSocketOrigin(r *http.Request) bool {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
return true
}
originURL, err := url.Parse(origin)
if err != nil || originURL.Host == "" {
return false
}
if strings.EqualFold(originURL.Host, r.Host) {
return true
}
// Behind a TLS-terminating CDN/proxy, r.Host is the internal origin host
// while the public host the browser used arrives as X-Forwarded-Host. The
// browser's Origin reflects that public host, so accept it too.
if fwd := forwardedHost(r); fwd != "" && strings.EqualFold(originURL.Host, fwd) {
return true
}
return false
}
func configureWebSocket(conn *websocket.Conn) {
if conn == nil {
return
}
_ = conn.SetReadDeadline(time.Now().Add(wsPingInterval + wsPongTimeout))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(wsPingInterval + wsPongTimeout))
})
}
func startWebSocketPingLoop(ctx context.Context, writePing func() error) {
if writePing == nil {
return
}
go func() {
ticker := time.NewTicker(wsPingInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := writePing(); err != nil {
return
}
}
}
}()
}
func writeWebSocketError(conn *websocket.Conn, code, message string) {
if conn == nil {
return
}
_ = conn.WriteJSON(map[string]string{
"type": "error",
"code": code,
"message": message,
})
}
func writeWebSocketJSON(conn *websocket.Conn, value any) error {
if conn == nil {
return websocket.ErrCloseSent
}
if err := conn.SetWriteDeadline(time.Now().Add(wsWriteTimeout)); err != nil {
return err
}
defer conn.SetWriteDeadline(time.Time{})
return conn.WriteJSON(value)
}
func writeWebSocketControl(conn *websocket.Conn, messageType int, data []byte) error {
if conn == nil {
return websocket.ErrCloseSent
}
return conn.WriteControl(messageType, data, time.Now().Add(wsWriteTimeout))
}
func readWebSocketJSON[T any](data []byte, target *T) error {
return json.Unmarshal(data, target)
}