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
+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)
})
}