feat: CRUD rate limit and improve security
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m18s

This commit is contained in:
juancwu 2026-02-15 01:47:25 +00:00
commit 696cb6a2fa
5 changed files with 144 additions and 42 deletions

View file

@ -4,6 +4,7 @@ import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"log/slog"
"net/http"
"strings"
@ -23,7 +24,12 @@ func CSRFProtection(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip CSRF check for safe methods (GET, HEAD, OPTIONS)
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
token := getOrGenerateCSRFToken(w, r)
token, err := getOrGenerateCSRFToken(w, r)
if err != nil {
slog.Error("failed to generate CSRF token", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ctx := ctxkeys.WithCSRFToken(r.Context(), token)
next.ServeHTTP(w, r.WithContext(ctx))
return
@ -36,7 +42,12 @@ func CSRFProtection(next http.Handler) http.Handler {
}
// Validate CSRF token for state-changing methods (POST, PUT, PATCH, DELETE)
token := getOrGenerateCSRFToken(w, r)
token, err := getOrGenerateCSRFToken(w, r)
if err != nil {
slog.Error("failed to generate CSRF token", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ctx := ctxkeys.WithCSRFToken(r.Context(), token)
// Get submitted token - try multiple sources in priority order
@ -64,13 +75,16 @@ func CSRFProtection(next http.Handler) http.Handler {
}
// getOrGenerateCSRFToken retrieves existing token or generates new one
func getOrGenerateCSRFToken(w http.ResponseWriter, r *http.Request) string {
func getOrGenerateCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) {
cookie, err := r.Cookie(csrfCookieName)
if err == nil && cookie.Value != "" && len(cookie.Value) == base64.RawURLEncoding.EncodedLen(csrfTokenLen) {
return cookie.Value
return cookie.Value, nil
}
token := generateCSRFToken()
token, err := generateCSRFToken()
if err != nil {
return "", err
}
cfg := ctxkeys.Config(r.Context())
isProduction := cfg != nil && cfg.IsProduction()
@ -86,17 +100,17 @@ func getOrGenerateCSRFToken(w http.ResponseWriter, r *http.Request) string {
MaxAge: 86400 * 7, // 7 days
})
return token
return token, nil
}
// generateCSRFToken creates cryptographically secure random token
func generateCSRFToken() string {
func generateCSRFToken() (string, error) {
bytes := make([]byte, csrfTokenLen)
_, err := rand.Read(bytes)
if err != nil {
panic("failed to generate csrf token: " + err.Error())
return "", fmt.Errorf("failed to generate csrf token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(bytes)
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
// validCSRFToken performs constant-time comparison of tokens

View file

@ -122,6 +122,29 @@ func RateLimitAuth() func(http.HandlerFunc) http.HandlerFunc {
}
}
// RateLimitCRUD creates middleware for state-changing CRUD endpoints.
// Limits: 60 requests per minute per IP.
func RateLimitCRUD() func(http.Handler) http.Handler {
limiter := NewRateLimiter(60, 1*time.Minute)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := getClientIP(r)
if !limiter.Allow(ip) {
slog.Warn("CRUD rate limit exceeded",
"ip", ip,
"path", r.URL.Path,
)
http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
// getClientIP extracts real client IP from request
func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header (proxy/load balancer)

View file

@ -0,0 +1,33 @@
package middleware
import (
"net/http"
)
// SecurityHeaders sets common security response headers on every response.
// Note: HSTS is handled by Caddy at the reverse proxy layer.
func SecurityHeaders() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; "+
"connect-src 'self' https://www.google-analytics.com; "+
"font-src 'self'; "+
"frame-ancestors 'none'; "+
"base-uri 'self'; "+
"form-action 'self'")
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
next.ServeHTTP(w, r)
})
}
}