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
+29 -20
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{
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
}