security: remediate all P0-P2 audit findings (12 tasks)

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).
This commit is contained in:
2026-06-29 09:30:50 -04:00
parent 5373105368
commit 130187c7be
23 changed files with 504 additions and 105 deletions
+3 -1
View File
@@ -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
+18 -3
View File
@@ -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)
})
})
+32 -8
View File
@@ -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{}),
+1
View File
@@ -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:-}
+3 -3
View File
@@ -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
+6
View File
@@ -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=
+20
View File
@@ -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()),
})
}
+1 -9
View File
@@ -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)
}
+2 -8
View File
@@ -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{
+19 -2
View File
@@ -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
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,9 +379,13 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
if ownerID != userID {
// 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(), `
DELETE FROM channels WHERE id = $1
+3 -1
View File
@@ -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 {
+4
View File
@@ -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,
+68 -19
View File
@@ -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,
+66
View File
@@ -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
}
+20 -11
View File
@@ -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{
// 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{
// 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
}
+49
View File
@@ -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)
})
}
}
+66
View File
@@ -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)
})
}
}
+19
View File
@@ -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)
})
}
+4 -4
View File
@@ -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,
+46 -8
View File
@@ -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)
}
+14 -10
View File
@@ -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,13 +396,16 @@ 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{
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)
}
+7
View File
@@ -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[:])
}
+18 -3
View File
@@ -70,7 +70,7 @@ export const useWebSocketStore = create<WebSocketState>((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<WebSocketState>((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<WebSocketState>((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;