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