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
+17 -13
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,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)
+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[:])
}