feat: CRUD rate limit and improve security
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m18s
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m18s
This commit is contained in:
parent
f0d5cc459a
commit
696cb6a2fa
5 changed files with 144 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
33
internal/middleware/security_headers.go
Normal file
33
internal/middleware/security_headers.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue