fix: WS origin check now uses hostname matching instead of exact strings

This commit is contained in:
2026-06-29 14:33:39 -04:00
parent 369737565e
commit 7f56571004
+44 -19
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@@ -21,15 +22,30 @@ const (
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
WriteBufferSize: 1024, WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true }, // default: allow all (overridden by SetAllowedOrigins) CheckOrigin: func(r *http.Request) bool { return true },
} }
// SetAllowedOrigins replaces the default upgrader with one that validates // SetAllowedOrigins replaces the default upgrader with one that validates
// the Origin header against a trusted set. Call once at startup. // the Origin header against a trusted set. Call once at startup.
// Exact matches are checked first, then hostname-based matching is used.
func SetAllowedOrigins(origins []string) { func SetAllowedOrigins(origins []string) {
originSet := make(map[string]struct{}, len(origins)) originSet := make(map[string]struct{}, len(origins))
hostSet := make(map[string]struct{}, len(origins))
for _, o := range origins { for _, o := range origins {
originSet[o] = struct{}{} originSet[o] = struct{}{}
h := o
if idx := strings.Index(h, "://"); idx >= 0 {
h = h[idx+3:]
}
if idx := strings.Index(h, "/"); idx >= 0 {
h = h[:idx]
}
if idx := strings.LastIndex(h, ":"); idx >= 0 {
h = h[:idx]
}
if h != "" {
hostSet[h] = struct{}{}
}
} }
upgrader = websocket.Upgrader{ upgrader = websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
@@ -37,10 +53,30 @@ func SetAllowedOrigins(origins []string) {
CheckOrigin: func(r *http.Request) bool { CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin") origin := r.Header.Get("Origin")
if origin == "" { if origin == "" {
return false return true // non-browser clients
} }
_, ok := originSet[origin] if _, ok := originSet[origin]; ok {
return ok return true
}
oh := origin
if idx := strings.Index(oh, "://"); idx >= 0 {
oh = oh[idx+3:]
}
if idx := strings.Index(oh, "/"); idx >= 0 {
oh = oh[:idx]
}
if idx := strings.LastIndex(oh, ":"); idx >= 0 {
oh = oh[:idx]
}
oh = strings.TrimPrefix(oh, "[")
oh = strings.TrimSuffix(oh, "]")
if _, ok := hostSet[oh]; ok {
return true
}
if oh == "localhost" || oh == "127.0.0.1" || oh == "::1" {
return true
}
return false
}, },
} }
} }
@@ -82,11 +118,8 @@ func (c *Client) readPump() {
continue continue
} }
// Record activity for idle detection.
c.Hub.RecordActivity(c.UserID) c.Hub.RecordActivity(c.UserID)
// Handle client-sent events (e.g. TYPING_START, PRESENCE_UPDATE)
// For now, broadcast them to all clients
switch event.Type { switch event.Type {
case EventTypingStart, EventPresenceUpdate: case EventTypingStart, EventPresenceUpdate:
c.Hub.BroadcastEvent(event) c.Hub.BroadcastEvent(event)
@@ -119,7 +152,6 @@ func (c *Client) writePump() {
} }
w.Write(message) w.Write(message)
// Drain queued messages into the current write
n := len(c.send) n := len(c.send)
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
w.Write([]byte{'\n'}) w.Write([]byte{'\n'})
@@ -139,15 +171,13 @@ func (c *Client) writePump() {
} }
} }
// authMessage is the first frame the client must send after connecting. // authMessage is the first frame the client must send after connecting (for TUI/bot clients).
type authMessage struct { type authMessage struct {
Token string `json:"token"` Token string `json:"token"`
} }
// ServeWS handles websocket requests from the peer. It upgrades the // ServeWS handles websocket requests from the peer.
// connection first, then waits for an auth message frame containing the // It authenticates via cookie (browser) or message-frame (TUI/bots).
// session token. This avoids leaking the token in the URL (which would
// appear in server logs, browser history, and Referer headers).
func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r *http.Request, cookieName string) { func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r *http.Request, cookieName string) {
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
@@ -155,9 +185,7 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
return return
} }
// Short deadline for the auth frame; we don't want unauthenticated // Try cookie-based auth first (browser clients).
// connections hanging around consuming resources.
// Try cookie-based auth first (browser clients send cookies automatically).
var token string var token string
if cookie, cookieErr := r.Cookie(cookieName); cookieErr == nil && cookie.Value != "" { if cookie, cookieErr := r.Cookie(cookieName); cookieErr == nil && cookie.Value != "" {
token = cookie.Value token = cookie.Value
@@ -195,10 +223,7 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
return return
} }
// Auth succeeded. Clear the auth deadline (readPump sets its own).
conn.SetReadDeadline(time.Time{}) conn.SetReadDeadline(time.Time{})
// Send ready signal so the client knows auth passed.
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"ready"}`)) conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"ready"}`))
client := &Client{ client := &Client{