diff --git a/.env.example b/.env.example index a8bee82..dc593f8 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,9 @@ DUMPSTER_SECRET=*** Database POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_USER=dumpster -POSTGRES_PASSWORD=*** Valkey +POSTGRES_PASSWORD=dumpster +POSTGRES_SSLMODE=disable +# Valkey VALKEY_URL=redis://localhost:***@example.com # MinIO MINIO_ENDPOINT=minio:9000 diff --git a/cmd/server/main.go b/cmd/server/main.go index 71d3b92..0fce2f9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -18,6 +18,7 @@ import ( "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/reaction" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server" @@ -58,6 +59,13 @@ func main() { // Session store for middleware sessionStore := auth.NewSessionStore(database.DB, cfg) + // WebSocket origin allowlist + wsOrigins := []string{"https://" + cfg.Host} + if cfg.Host == "localhost" { + wsOrigins = append(wsOrigins, "http://localhost:"+cfg.Port) + } + gateway.SetAllowedOrigins(wsOrigins) + // WebSocket hub hub := gateway.NewHub(database.DB, logger) go hub.Run() @@ -86,10 +94,14 @@ func main() { // Auth handler authHandler := auth.NewHandler(database.DB, cfg) + // Permissions checker + permissionsChecker := permissions.NewChecker(database.DB) + r := chi.NewRouter() r.Use(chimw.Logger) r.Use(chimw.Recoverer) r.Use(chimw.RequestID) + r.Use(middleware.SecurityHeaders) // Health check r.Get("/health", func(w http.ResponseWriter, r *http.Request) { @@ -104,8 +116,9 @@ func main() { // API routes r.Route("/api/v1", func(r chi.Router) { - // Auth (public: register, login, logout) + // Auth (public: register, login, logout) with strict rate limiting r.Route("/auth", func(r chi.Router) { + r.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10 authHandler.RegisterPublicRoutes(r) }) @@ -113,6 +126,7 @@ func main() { r.Group(func(r chi.Router) { r.Use(middleware.Session(sessionStore, cfg)) r.Use(middleware.RequireAuth) + r.Use(middleware.CSRFProtect([]string{"https://" + cfg.Host, "http://localhost:" + cfg.Port})) // Auth (protected: me, update profile) authHandler.RegisterProtectedRoutes(r) @@ -123,7 +137,7 @@ func main() { // Channels (nested under servers) r.Route("/{serverID}/channels", func(r chi.Router) { - channel.NewHandler(database.DB).RegisterRoutes(r) + channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r) }) }) @@ -191,8 +205,9 @@ func main() { webhook.NewHandler(database.DB, hub).RegisterRoutes(r) }) - // Invites + // Invites (rate limited to prevent brute-force join) r.Route("/invites", func(r chi.Router) { + r.Use(middleware.RateLimit(2, 5)) invite.NewHandler(database.DB).RegisterRoutes(r) }) }) diff --git a/cmd/tui/ws.go b/cmd/tui/ws.go index 79fff2e..6dfd80a 100644 --- a/cmd/tui/ws.go +++ b/cmd/tui/ws.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "log" "net/url" "strings" @@ -26,7 +27,8 @@ type WSConn struct { closed bool } -// ConnectWS dials the websocket endpoint with the session cookie. +// ConnectWS dials the websocket endpoint and authenticates via an initial +// message frame (not a URL query parameter, to avoid leaking the token in logs). func ConnectWS(serverURL, token string) (*WSConn, error) { u, err := url.Parse(serverURL) if err != nil { @@ -39,18 +41,40 @@ func ConnectWS(serverURL, token string) (*WSConn, error) { u.Scheme = "ws" } u.Path = "/ws" - q := u.Query() - q.Set("token", token) - u.RawQuery = q.Encode() - header := make(map[string][]string) - header["Cookie"] = []string{"dumpster_session=" + token} - - conn, _, err := websocket.DefaultDialer.Dial(u.String(), header) + conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) if err != nil { return nil, err } + // Send auth token as the first message frame. + authMsg, _ := json.Marshal(map[string]string{"token": token}) + if err := conn.WriteMessage(websocket.TextMessage, authMsg); err != nil { + conn.Close() + return nil, fmt.Errorf("send auth: %w", err) + } + + // Wait for the server's ready response. + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + _, raw, err := conn.ReadMessage() + if err != nil { + conn.Close() + return nil, fmt.Errorf("auth timeout: %w", err) + } + var resp struct { + Type string `json:"type"` + Error string `json:"error"` + } + if err := json.Unmarshal(raw, &resp); err != nil || resp.Type != "ready" { + conn.Close() + msg := "unexpected response" + if resp.Error != "" { + msg = resp.Error + } + return nil, fmt.Errorf("auth failed: %s", msg) + } + conn.SetReadDeadline(time.Time{}) + ws := &WSConn{ conn: conn, done: make(chan struct{}), diff --git a/docker/compose.yml b/docker/compose.yml index afcc739..e037e3d 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -15,6 +15,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-dumpster} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dumpster} POSTGRES_DB: ${POSTGRES_DB:-dumpster} + POSTGRES_SSLMODE: ${POSTGRES_SSLMODE:-disable} VALKEY_URL: redis://valkey:6379 MINIO_ENDPOINT: ${MINIO_ENDPOINT:-} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-} diff --git a/go.mod b/go.mod index 86c384e..17f0ff7 100644 --- a/go.mod +++ b/go.mod @@ -15,8 +15,11 @@ require ( github.com/livekit/protocol v1.48.1-0.20260624204523-bd5703442db6 github.com/livekit/server-sdk-go/v2 v2.16.7 github.com/minio/minio-go/v7 v7.2.1 + github.com/swaggo/http-swagger v1.3.4 + github.com/swaggo/swag v1.8.1 golang.org/x/crypto v0.53.0 golang.org/x/term v0.44.0 + golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -110,8 +113,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.6.0 // indirect github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect - github.com/swaggo/http-swagger v1.3.4 // indirect - github.com/swaggo/swag v1.8.1 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/twitchtv/twirp v8.1.3+incompatible // indirect github.com/wlynxg/anet v0.0.5 // indirect @@ -129,7 +130,6 @@ require ( golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect - golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.45.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect diff --git a/go.sum b/go.sum index 06211af..f02daf6 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= +github.com/agiledragon/gomonkey/v2 v2.3.1 h1:k+UnUY0EMNYUFUAQVETGY9uUTxjMdnUkP0ARyJS1zzs= +github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -209,6 +211,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME= github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8= +github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE= +github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= @@ -346,6 +350,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= diff --git a/internal/auth/cookie.go b/internal/auth/cookie.go new file mode 100644 index 0000000..d6e4872 --- /dev/null +++ b/internal/auth/cookie.go @@ -0,0 +1,20 @@ +package auth + +import ( + "net/http" + "time" +) + +// SetSessionCookie writes the session cookie with secure defaults. +// Shared by password login, registration, and WebAuthn login. +func SetSessionCookie(w http.ResponseWriter, cookieName, token string, duration time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: int(duration.Seconds()), + }) +} diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index 1e0ab01..24d08ee 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -293,13 +293,5 @@ func (h *Handler) getProfile(ctx context.Context, userID string) (UserProfile, e } func (h *Handler) setSessionCookie(w http.ResponseWriter, token string) { - http.SetCookie(w, &http.Cookie{ - Name: h.cfg.Session.CookieName, - Value: token, - Path: "/", - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteStrictMode, - MaxAge: int(h.cfg.Session.Duration.Seconds()), - }) + SetSessionCookie(w, h.cfg.Session.CookieName, token, h.cfg.Session.Duration) } diff --git a/internal/auth/webauthn.go b/internal/auth/webauthn.go index 818a639..68ec723 100644 --- a/internal/auth/webauthn.go +++ b/internal/auth/webauthn.go @@ -9,6 +9,7 @@ import ( "log/slog" "net/http" "sync" + "time" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" @@ -331,14 +332,7 @@ func (h *WebAuthnHandler) LoginFinish(w http.ResponseWriter, r *http.Request) { return } - http.SetCookie(w, &http.Cookie{ - Name: "dumpster_session", - Value: token, - Path: "/", - HttpOnly: true, - MaxAge: 86400 * 30, // 30 days - SameSite: http.SameSiteStrictMode, - }) + SetSessionCookie(w, "dumpster_session", token, 30*24*time.Hour) // Clear challenge cookie http.SetCookie(w, &http.Cookie{ diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go index e268afd..9ec0bdb 100644 --- a/internal/channel/handlers.go +++ b/internal/channel/handlers.go @@ -8,15 +8,17 @@ import ( "net/http" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" "github.com/go-chi/chi/v5" ) type Handler struct { - db *sql.DB + db *sql.DB + checker *permissions.Checker } -func NewHandler(db *sql.DB) *Handler { - return &Handler{db: db} +func NewHandler(db *sql.DB, checker *permissions.Checker) *Handler { + return &Handler{db: db, checker: checker} } func (h *Handler) RegisterRoutes(r chi.Router) { @@ -296,6 +298,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { return } + // Verify membership and MANAGE_CHANNELS permission member, err := h.isMember(r.Context(), userID, serverID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) @@ -306,6 +309,16 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { return } + allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if !allowed { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + var ch channelResponse err = h.db.QueryRowContext(r.Context(), ` UPDATE channels @@ -366,8 +379,12 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { return } if ownerID != userID { - http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) - return + // Not the owner; check MANAGE_CHANNELS permission + allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS) + if err != nil || !allowed { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } } _, err = h.db.ExecContext(r.Context(), ` diff --git a/internal/config/config.go b/internal/config/config.go index 9b7f2fa..e7c846b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ type DatabaseConfig struct { User string Password string Database string + SSLMode string } type ValkeyConfig struct { @@ -70,6 +71,7 @@ func Load() *Config { User: getEnv("POSTGRES_USER", "dumpster"), Password: getEnv("POSTGRES_PASSWORD", "dumpster"), Database: getEnv("POSTGRES_DB", "dumpster"), + SSLMode: getEnv("POSTGRES_SSLMODE", "require"), }, Valkey: ValkeyConfig{ URL: getEnv("VALKEY_URL", "redis://localhost:***@example.com"), @@ -92,7 +94,7 @@ func Load() *Config { } func (c *Config) DatabaseDSN() string { - return "postgres://" + c.Database.User + ":***@" + c.Database.Host + ":" + c.Database.Port + "/" + c.Database.Database + "?sslmode=disable" + return "postgres://" + c.Database.User + ":" + c.Database.Password + "@" + c.Database.Host + ":" + c.Database.Port + "/" + c.Database.Database + "?sslmode=" + c.Database.SSLMode } func getEnv(key, fallback string) string { diff --git a/internal/db/db.go b/internal/db/db.go index e479eeb..a46497f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -185,6 +185,10 @@ CREATE INDEX IF NOT EXISTS idx_slash_commands_server ON slash_commands(server_id CREATE INDEX IF NOT EXISTS idx_webhooks_channel ON webhooks(channel_id); CREATE INDEX IF NOT EXISTS idx_webhooks_token ON webhooks(token); +-- Add token_hash column for secure webhook token storage +ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS token_hash VARCHAR(64); +CREATE INDEX IF NOT EXISTS idx_webhooks_token_hash ON webhooks(token_hash); + CREATE TABLE IF NOT EXISTS push_subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/internal/gateway/client.go b/internal/gateway/client.go index cac4301..a595c3f 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -21,7 +21,28 @@ const ( var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: func(r *http.Request) bool { return true }, // default: allow all (overridden by SetAllowedOrigins) +} + +// SetAllowedOrigins replaces the default upgrader with one that validates +// the Origin header against a trusted set. Call once at startup. +func SetAllowedOrigins(origins []string) { + originSet := make(map[string]struct{}, len(origins)) + for _, o := range origins { + originSet[o] = struct{}{} + } + upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return false + } + _, ok := originSet[origin] + return ok + }, + } } // Client is a middleman between the websocket connection and the hub. @@ -118,31 +139,59 @@ func (c *Client) writePump() { } } -// ServeWS handles websocket requests from the peer. It authenticates the -// client via a session token passed as the "token" query parameter. +// authMessage is the first frame the client must send after connecting. +type authMessage struct { + Token string `json:"token"` +} + +// ServeWS handles websocket requests from the peer. It upgrades the +// connection first, then waits for an auth message frame containing the +// 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) { - token := r.URL.Query().Get("token") - if token == "" { - http.Error(w, `{"error":"missing token"}`, http.StatusUnauthorized) - return - } - - var userID string - err := db.QueryRowContext(context.Background(), - `SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW()`, - token, - ).Scan(&userID) - if err != nil { - http.Error(w, `{"error":"invalid or expired session"}`, http.StatusUnauthorized) - return - } - conn, err := upgrader.Upgrade(w, r, nil) if err != nil { logger.Error("websocket upgrade failed", "error", err) return } + // Short deadline for the auth frame; we don't want unauthenticated + // connections hanging around consuming resources. + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + _, raw, err := conn.ReadMessage() + if err != nil { + logger.Warn("ws auth: failed to read auth message", "error", err) + conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"auth timeout"}`)) + conn.Close() + return + } + + var auth authMessage + if err := json.Unmarshal(raw, &auth); err != nil || auth.Token == "" { + logger.Warn("ws auth: invalid auth message") + conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"missing token"}`)) + conn.Close() + return + } + + var userID string + err = db.QueryRowContext(context.Background(), + `SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW()`, + auth.Token, + ).Scan(&userID) + if err != nil { + logger.Warn("ws auth: invalid session", "error", err) + conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid session"}`)) + conn.Close() + return + } + + // Auth succeeded. Clear the auth deadline (readPump sets its own). + conn.SetReadDeadline(time.Time{}) + + // Send ready signal so the client knows auth passed. + conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"ready"}`)) + client := &Client{ Hub: hub, Conn: conn, diff --git a/internal/gateway/hub.go b/internal/gateway/hub.go index 3b86d2e..8028600 100644 --- a/internal/gateway/hub.go +++ b/internal/gateway/hub.go @@ -28,6 +28,7 @@ type Hub struct { clients map[*Client]struct{} presence map[string]string // userID -> status lastActivity map[string]time.Time // userID -> last activity time + userServers map[string]map[string]struct{} // userID -> set of serverIDs register chan *Client unregister chan *Client broadcast chan []byte @@ -41,6 +42,7 @@ func NewHub(db *sql.DB, logger *slog.Logger) *Hub { clients: make(map[*Client]struct{}), presence: make(map[string]string), lastActivity: make(map[string]time.Time), + userServers: make(map[string]map[string]struct{}), register: make(chan *Client), unregister: make(chan *Client), broadcast: make(chan []byte, 256), @@ -63,6 +65,7 @@ func (h *Hub) Run() { h.lastActivity[client.UserID] = time.Now() h.mu.Unlock() + h.RefreshUserServers(client.UserID) h.setUserStatus(client.UserID, "online") h.broadcastPresence(client.UserID, "online") h.logger.Info("client connected", "user_id", client.UserID) @@ -79,6 +82,7 @@ func (h *Hub) Run() { if !hasOther { delete(h.presence, client.UserID) delete(h.lastActivity, client.UserID) + delete(h.userServers, client.UserID) } h.mu.Unlock() @@ -207,3 +211,65 @@ func (h *Hub) ClientCount() int { defer h.mu.RUnlock() return len(h.clients) } + +// RefreshUserServers loads the server membership for a user from the database +// and caches it. Call when a client connects or when server membership changes. +func (h *Hub) RefreshUserServers(userID string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + rows, err := h.db.QueryContext(ctx, `SELECT server_id FROM members WHERE user_id = $1`, userID) + if err != nil { + h.logger.Error("failed to load user servers", "user_id", userID, "error", err) + return + } + defer rows.Close() + + servers := make(map[string]struct{}) + for rows.Next() { + var sid string + if err := rows.Scan(&sid); err == nil { + servers[sid] = struct{}{} + } + } + + h.mu.Lock() + h.userServers[userID] = servers + h.mu.Unlock() +} + +// BroadcastToServer sends an event only to clients who are members of the +// given server. Use this for server-scoped events (messages, channels, +// reactions) to prevent cross-server data leaks. +func (h *Hub) BroadcastToServer(serverID string, event Event) { + data, err := json.Marshal(event) + if err != nil { + h.logger.Error("failed to marshal event", "type", event.Type, "error", err) + return + } + + h.mu.RLock() + defer h.mu.RUnlock() + + for client := range h.clients { + servers, ok := h.userServers[client.UserID] + if !ok { + continue + } + if _, member := servers[serverID]; member { + select { + case client.send <- data: + default: + go func(c *Client) { h.unregister <- c }(client) + } + } + } +} + +// ServerIDForChannel looks up the server_id that owns the given channel. +// Used by handlers to route events to the correct server scope. +func (h *Hub) ServerIDForChannel(ctx context.Context, channelID string) (string, error) { + var serverID string + err := h.db.QueryRowContext(ctx, `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID) + return serverID, err +} diff --git a/internal/message/handlers.go b/internal/message/handlers.go index 4aabf2e..42390ee 100644 --- a/internal/message/handlers.go +++ b/internal/message/handlers.go @@ -76,7 +76,7 @@ type createMessageRequest struct { // @Router /channels/{channelID}/messages [post] func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { channelID := chi.URLParam(r, "channelID") - userID, ok := h.requireChannelAccess(w, r, channelID) + userID, serverID, ok := h.requireChannelAccess(w, r, channelID) if !ok { return } @@ -124,8 +124,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { } msg.CreatedAt = createdAt.String - // Broadcast MESSAGE_CREATE event via WebSocket - h.hub.BroadcastEvent(gateway.Event{ + // Broadcast MESSAGE_CREATE event via WebSocket (scoped to server) + h.hub.BroadcastToServer(serverID, gateway.Event{ Type: gateway.EventMessageCreate, Data: msg, }) @@ -206,10 +206,14 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { } msg.CreatedAt = createdAt.String - h.hub.BroadcastEvent(gateway.Event{ - Type: gateway.EventMessageUpdate, - Data: msg, - }) + // Look up serverID for scoped broadcast + serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID) + if serverID != "" { + h.hub.BroadcastToServer(serverID, gateway.Event{ + Type: gateway.EventMessageUpdate, + Data: msg, + }) + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(msg) @@ -247,13 +251,17 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { return } - h.hub.BroadcastEvent(gateway.Event{ - Type: gateway.EventMessageDelete, - Data: map[string]string{ - "id": messageID, - "channel_id": channelID, - }, - }) + // Look up serverID for scoped broadcast + serverID, _ := h.hub.ServerIDForChannel(r.Context(), channelID) + if serverID != "" { + h.hub.BroadcastToServer(serverID, gateway.Event{ + Type: gateway.EventMessageDelete, + Data: map[string]string{ + "id": messageID, + "channel_id": channelID, + }, + }) + } w.WriteHeader(http.StatusNoContent) } @@ -272,7 +280,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { // @Router /channels/{channelID}/messages [get] func (h *Handler) List(w http.ResponseWriter, r *http.Request) { channelID := chi.URLParam(r, "channelID") - userID, ok := h.requireChannelAccess(w, r, channelID) + userID, _, ok := h.requireChannelAccess(w, r, channelID) if !ok { return } @@ -337,25 +345,26 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { } // requireChannelAccess checks if user is authenticated and is a member of the channel's server. -func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, bool) { +// Returns (userID, serverID, ok). +func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, string, bool) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) - return "", false + return "", "", false } var serverID string err := h.db.QueryRowContext(r.Context(), `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID) if err != nil { http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) - return "", false + return "", "", false } isMember, err := h.isMember(r.Context(), userID, serverID) if err != nil || !isMember { http.Error(w, `{"error":"not a member"}`, http.StatusForbidden) - return "", false + return "", "", false } - return userID, true + return userID, serverID, true } diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go new file mode 100644 index 0000000..4bbb6bb --- /dev/null +++ b/internal/middleware/csrf.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "net/http" + "strings" +) + +// CSRFProtect returns middleware that validates the Origin header on +// state-changing methods (POST, PUT, PATCH, DELETE). Requests from origins +// not in the trustedOrigins list are rejected with 403. Non-browser clients +// (curl, bots) that send no Origin or Referer header are allowed through, +// since they are not subject to CSRF. +func CSRFProtect(trustedOrigins []string) func(http.Handler) http.Handler { + originSet := make(map[string]struct{}, len(trustedOrigins)) + for _, o := range trustedOrigins { + originSet[o] = struct{}{} + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "POST", "PUT", "PATCH", "DELETE": + origin := r.Header.Get("Origin") + if origin == "" { + // Fallback: extract origin from Referer + referer := r.Header.Get("Referer") + if referer != "" { + for _, o := range trustedOrigins { + if strings.HasPrefix(referer, o) { + origin = o + break + } + } + } + } + + if origin != "" { + if _, ok := originSet[origin]; !ok { + http.Error(w, `{"error":"forbidden origin"}`, http.StatusForbidden) + return + } + } + // If both Origin and Referer are empty, allow through + // (non-browser client like curl, bots, mobile apps) + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..eae1129 --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,66 @@ +package middleware + +import ( + "net/http" + "strings" + "sync" + + "golang.org/x/time/rate" +) + +// ipLimiter tracks rate limiters per IP address. +type ipLimiter struct { + mu sync.Mutex + limiters map[string]*rate.Limiter + rate rate.Limit + burst int +} + +func newIPLimiter(r rate.Limit, burst int) *ipLimiter { + return &ipLimiter{ + limiters: make(map[string]*rate.Limiter), + rate: r, + burst: burst, + } +} + +func (l *ipLimiter) getLimiter(ip string) *rate.Limiter { + l.mu.Lock() + defer l.mu.Unlock() + + lim, exists := l.limiters[ip] + if !exists { + lim = rate.NewLimiter(l.rate, l.burst) + l.limiters[ip] = lim + } + return lim +} + +// RateLimit returns middleware that limits requests per IP. +// requestsPerSecond is the sustained rate, burst is the max burst size. +func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler { + limiter := newIPLimiter(rate.Limit(requestsPerSecond), burst) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := r.RemoteAddr + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + // Take the first IP in the chain (the original client) + if idx := strings.IndexByte(xff, ','); idx > 0 { + ip = strings.TrimSpace(xff[:idx]) + } else { + ip = strings.TrimSpace(xff) + } + } else if xri := r.Header.Get("X-Real-IP"); xri != "" { + ip = strings.TrimSpace(xri) + } + + if !limiter.getLimiter(ip).Allow() { + http.Error(w, `{"error":"rate limited"}`, http.StatusTooManyRequests) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/middleware/security.go b/internal/middleware/security.go new file mode 100644 index 0000000..81b87da --- /dev/null +++ b/internal/middleware/security.go @@ -0,0 +1,19 @@ +package middleware + +import "net/http" + +// SecurityHeaders returns middleware that sets common security headers on +// every response. HSTS is intentionally omitted here because Caddy handles +// it at the reverse-proxy layer. +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+ + "img-src 'self' https: data:; connect-src 'self' wss: ws:; font-src 'self';") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(self), geolocation=()") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/reaction/handlers.go b/internal/reaction/handlers.go index 476d2e0..d9e73be 100644 --- a/internal/reaction/handlers.go +++ b/internal/reaction/handlers.go @@ -114,7 +114,7 @@ func (h *Handler) requireMessageAccess(w http.ResponseWriter, r *http.Request, m // @Router /messages/{messageID}/reactions [post] func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { messageID := chi.URLParam(r, "messageID") - userID, channelID, _, ok := h.requireMessageAccess(w, r, messageID) + userID, channelID, serverID, ok := h.requireMessageAccess(w, r, messageID) if !ok { return } @@ -145,7 +145,7 @@ func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { } reaction.CreatedAt = createdAt.String - h.hub.BroadcastEvent(gateway.Event{ + h.hub.BroadcastToServer(serverID, gateway.Event{ Type: gateway.EventReactionAdd, Data: map[string]interface{}{ "reaction": reaction, @@ -173,7 +173,7 @@ func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { func (h *Handler) Remove(w http.ResponseWriter, r *http.Request) { messageID := chi.URLParam(r, "messageID") emoji := chi.URLParam(r, "emoji") - userID, channelID, _, ok := h.requireMessageAccess(w, r, messageID) + userID, channelID, serverID, ok := h.requireMessageAccess(w, r, messageID) if !ok { return } @@ -196,7 +196,7 @@ func (h *Handler) Remove(w http.ResponseWriter, r *http.Request) { return } - h.hub.BroadcastEvent(gateway.Event{ + h.hub.BroadcastToServer(serverID, gateway.Event{ Type: gateway.EventReactionRemove, Data: map[string]interface{}{ "user_id": userID, diff --git a/internal/upload/handlers.go b/internal/upload/handlers.go index aec8a14..f8cf595 100644 --- a/internal/upload/handlers.go +++ b/internal/upload/handlers.go @@ -14,6 +14,16 @@ import ( "github.com/minio/minio-go/v7/pkg/credentials" ) +const maxUploadSize = 25 << 20 // 25 MB per file + +var allowedExtensions = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, + ".mp3": true, ".wav": true, ".ogg": true, ".flac": true, + ".mp4": true, ".webm": true, ".mov": true, + ".pdf": true, ".txt": true, ".md": true, ".json": true, ".csv": true, + ".zip": true, ".tar": true, ".gz": true, +} + type Handler struct { client *minio.Client bucket string @@ -53,8 +63,10 @@ func NewHandler(cfg *config.Config) (*Handler, error) { } func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { - if err := r.ParseMultipartForm(50 << 20); err != nil { - http.Error(w, `{"error":"invalid form"}`, http.StatusBadRequest) + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) + + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + http.Error(w, `{"error":"file too large or invalid form"}`, http.StatusBadRequest) return } @@ -65,14 +77,33 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { } defer file.Close() - contentType := header.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/octet-stream" - } - + // Validate extension against allowlist ext := "" if idx := strings.LastIndex(header.Filename, "."); idx >= 0 { - ext = header.Filename[idx:] + ext = strings.ToLower(header.Filename[idx:]) + } + if !allowedExtensions[ext] { + http.Error(w, `{"error":"file type not allowed"}`, http.StatusBadRequest) + return + } + + // Server-side content-type detection (blocks HTML/JS that could be used for XSS) + buf := make([]byte, 512) + n, _ := file.Read(buf) + detectedType := http.DetectContentType(buf[:n]) + file.Seek(0, io.SeekStart) + + blockedTypes := []string{"text/html", "application/javascript", "application/x-executable"} + for _, blocked := range blockedTypes { + if strings.HasPrefix(detectedType, blocked) { + http.Error(w, `{"error":"file type not allowed"}`, http.StatusBadRequest) + return + } + } + + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = detectedType } objectName := uuid.New().String() + ext @@ -98,6 +129,12 @@ func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) { return } + // Sanitize: reject path traversal attempts + if strings.Contains(objectName, "..") || strings.Contains(objectName, "/") { + http.Error(w, `{"error":"invalid path"}`, http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() @@ -116,5 +153,6 @@ func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", stat.ContentType) w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size)) + w.Header().Set("Content-Disposition", "attachment") io.Copy(w, obj) } diff --git a/internal/webhook/handlers.go b/internal/webhook/handlers.go index 4f057da..9389268 100644 --- a/internal/webhook/handlers.go +++ b/internal/webhook/handlers.go @@ -138,15 +138,16 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { } token := genToken() + tokenHash := hashToken(token) var wh webhookWithToken var avatar sql.NullString var createdAt sql.NullString err = h.db.QueryRowContext(r.Context(), ` - INSERT INTO webhooks (channel_id, name, token, avatar, created_by) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO webhooks (channel_id, name, token, token_hash, avatar, created_by) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, channel_id, name, avatar, created_by, created_at::text - `, channelID, req.Name, token, req.Avatar, userID).Scan( + `, channelID, req.Name, token, tokenHash, req.Avatar, userID).Scan( &wh.ID, &wh.ChannelID, &wh.Name, &avatar, &wh.CreatedBy, &createdAt, ) if err != nil { @@ -300,12 +301,12 @@ func (h *Handler) Execute(w http.ResponseWriter, r *http.Request) { webhookID := chi.URLParam(r, "webhookID") token := chi.URLParam(r, "token") - // Look up webhook and verify token - var storedToken, channelID, webhookName string + // Look up webhook and verify token via hash comparison + var storedHash, channelID, webhookName string var webhookAvatar sql.NullString err := h.db.QueryRowContext(r.Context(), ` - SELECT token, channel_id, name, avatar FROM webhooks WHERE id = $1 - `, webhookID).Scan(&storedToken, &channelID, &webhookName, &webhookAvatar) + SELECT token_hash, channel_id, name, avatar FROM webhooks WHERE id = $1 + `, webhookID).Scan(&storedHash, &channelID, &webhookName, &webhookAvatar) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeErr(w, http.StatusNotFound, "webhook not found") @@ -315,7 +316,7 @@ func (h *Handler) Execute(w http.ResponseWriter, r *http.Request) { return } - if token != storedToken { + if hashToken(token) != storedHash { writeErr(w, http.StatusUnauthorized, "invalid token") return } @@ -395,12 +396,15 @@ func (h *Handler) Execute(w http.ResponseWriter, r *http.Request) { msg.DisplayName = &displayName } - // Broadcast MESSAGE_CREATE via gateway + // Broadcast MESSAGE_CREATE via gateway (scoped to server) if h.hub != nil { - h.hub.BroadcastEvent(gateway.Event{ - Type: gateway.EventMessageCreate, - Data: msg, - }) + serverID, _ := h.hub.ServerIDForChannel(r.Context(), channelID) + if serverID != "" { + h.hub.BroadcastToServer(serverID, gateway.Event{ + Type: gateway.EventMessageCreate, + Data: msg, + }) + } } writeJSON(w, http.StatusCreated, msg) diff --git a/internal/webhook/token.go b/internal/webhook/token.go index 2a6aef6..ebeae56 100644 --- a/internal/webhook/token.go +++ b/internal/webhook/token.go @@ -2,6 +2,7 @@ package webhook import ( "crypto/rand" + "crypto/sha256" "encoding/hex" ) @@ -13,3 +14,9 @@ func genToken() string { } return hex.EncodeToString(b) } + +// hashToken returns the SHA-256 hex digest of a token, suitable for storage. +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} diff --git a/web/src/stores/ws.ts b/web/src/stores/ws.ts index a67b850..4238525 100644 --- a/web/src/stores/ws.ts +++ b/web/src/stores/ws.ts @@ -70,7 +70,7 @@ export const useWebSocketStore = create((set, get) => ({ return; } - const socket = new WebSocket(`${getWsHost()}/ws?token=${encodeURIComponent(token)}`); + const socket = new WebSocket(`${getWsHost()}/ws`); let reconnectDelay = 1000; const maxReconnectDelay = 30000; @@ -89,8 +89,9 @@ export const useWebSocketStore = create((set, get) => ({ }; socket.onopen = () => { - set({ connected: true }); - reconnectDelay = 1000; + // Send auth token as the first message frame instead of a URL param. + // This keeps the token out of server logs, browser history, and Referer headers. + socket.send(JSON.stringify({ token })); }; socket.onmessage = (event) => { @@ -101,6 +102,20 @@ export const useWebSocketStore = create((set, get) => ({ return; } + // Server sends {"type":"ready"} after successful auth. + if (data.type === 'ready') { + set({ connected: true }); + reconnectDelay = 1000; + return; + } + + if (data.type === 'error') { + // Auth failed or server sent an error. Close and don't reconnect. + console.error('ws error:', data); + socket.close(); + return; + } + if (data.type === 'ping') { get().send({ type: 'pong', payload: {} }); return;