130187c7be
P0 fixes: - WebSocket origin checking (reject untrusted origins) - WS session token moved from URL query param to first message frame - WebAuthn login cookie now uses Secure flag via shared SetSessionCookie - PostgreSQL sslmode configurable via POSTGRES_SSLMODE env (default: require) - Fixed DatabaseDSN to use real password instead of masked placeholder P1 fixes: - Per-IP rate limiting middleware (auth: 5 req/s, invites: 2 req/s) - Security headers on all responses (CSP, X-Frame-Options, nosniff, etc.) - WS broadcasts scoped to server members (prevents cross-server data leak) - Webhook tokens stored as SHA-256 hashes (not plaintext) P2 fixes: - CSRF protection via Origin header validation on state-changing requests - MANAGE_CHANNELS permission enforced on channel update/delete - Upload validation: 25MB limit, extension allowlist, server-side MIME check - File serve: path traversal protection + Content-Disposition: attachment Files: 23 changed, +499/-100. Builds clean (go build, go vet, npm build). Zero CVEs (govulncheck, npm audit).
231 lines
5.2 KiB
Go
231 lines
5.2 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// WSEvent represents a raw websocket event from the server.
|
|
type WSEvent struct {
|
|
Type string `json:"type"`
|
|
Data json.RawMessage `json:"data,omitempty"`
|
|
}
|
|
|
|
// WebSocket connection manager.
|
|
type WSConn struct {
|
|
conn *websocket.Conn
|
|
mu sync.Mutex
|
|
done chan struct{}
|
|
closed bool
|
|
}
|
|
|
|
// 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 {
|
|
return nil, err
|
|
}
|
|
// Switch to ws/wss scheme
|
|
if u.Scheme == "https" {
|
|
u.Scheme = "wss"
|
|
} else {
|
|
u.Scheme = "ws"
|
|
}
|
|
u.Path = "/ws"
|
|
|
|
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{}),
|
|
}
|
|
return ws, nil
|
|
}
|
|
|
|
// Listen starts reading messages and sends them as tea.Msgs to the program.
|
|
// It returns a tea.Cmd that listens for the next message.
|
|
func (ws *WSConn) Listen() tea.Cmd {
|
|
return func() tea.Msg {
|
|
_, raw, err := ws.conn.ReadMessage()
|
|
if err != nil {
|
|
return wsErrMsg{err: err}
|
|
}
|
|
|
|
var event WSEvent
|
|
if err := json.Unmarshal(raw, &event); err != nil {
|
|
return wsErrMsg{err: err}
|
|
}
|
|
|
|
return wsEventMsg(event)
|
|
}
|
|
}
|
|
|
|
// Send sends a JSON event to the server.
|
|
func (ws *WSConn) Send(eventType string, data interface{}) error {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
msg := map[string]interface{}{
|
|
"type": eventType,
|
|
"data": data,
|
|
}
|
|
payload, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ws.conn.WriteMessage(websocket.TextMessage, payload)
|
|
}
|
|
|
|
// Close closes the websocket connection.
|
|
func (ws *WSConn) Close() {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
if !ws.closed {
|
|
ws.closed = true
|
|
ws.conn.WriteMessage(websocket.CloseMessage,
|
|
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
|
ws.conn.Close()
|
|
close(ws.done)
|
|
}
|
|
}
|
|
|
|
// StartPing sends periodic pings to keep the connection alive.
|
|
func (ws *WSConn) StartPing() {
|
|
go func() {
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
ws.mu.Lock()
|
|
if !ws.closed {
|
|
_ = ws.conn.WriteMessage(websocket.PingMessage, nil)
|
|
}
|
|
ws.mu.Unlock()
|
|
case <-ws.done:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// ---- Message types for bubbletea ----
|
|
|
|
type wsEventMsg WSEvent
|
|
|
|
type wsErrMsg struct {
|
|
err error
|
|
}
|
|
|
|
// Parse helpers for ws event data
|
|
|
|
type wsMessageData struct {
|
|
ID string `json:"id"`
|
|
ChannelID string `json:"channel_id"`
|
|
AuthorID string `json:"author_id"`
|
|
AuthorName string `json:"author_username"`
|
|
DisplayName *string `json:"author_display_name"`
|
|
Content string `json:"content"`
|
|
EditedAt *string `json:"edited_at"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
type wsPresenceData struct {
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type wsTypingData struct {
|
|
ChannelID string `json:"channel_id"`
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
type wsVoiceData struct {
|
|
RoomName string `json:"room_name"`
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
// parseWSEventMessage extracts a Message from event data.
|
|
func parseWSEventMessage(data json.RawMessage) (*Message, error) {
|
|
var m Message
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
// parseWSDeleteData extracts the message ID from a delete event.
|
|
func parseWSDeleteData(data json.RawMessage) (id string, channelID string) {
|
|
var d struct {
|
|
ID string `json:"id"`
|
|
ChannelID string `json:"channel_id"`
|
|
}
|
|
if err := json.Unmarshal(data, &d); err != nil {
|
|
log.Printf("ws: failed to parse delete data: %v", err)
|
|
return
|
|
}
|
|
return d.ID, d.ChannelID
|
|
}
|
|
|
|
// parseWSTyping extracts typing event data.
|
|
func parseWSTyping(data json.RawMessage) (username string, channelID string) {
|
|
var d struct {
|
|
ChannelID string `json:"channel_id"`
|
|
Username string `json:"username"`
|
|
}
|
|
if err := json.Unmarshal(data, &d); err != nil {
|
|
return
|
|
}
|
|
return d.Username, d.ChannelID
|
|
}
|
|
|
|
// trimURLScheme strips http(s):// for display
|
|
func trimURLScheme(s string) string {
|
|
s = strings.TrimPrefix(s, "https://")
|
|
s = strings.TrimPrefix(s, "http://")
|
|
return s
|
|
}
|