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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user