From a63d2dbf6289f4ed0ed669070a26b93185ffba43 Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Thu, 25 Jun 2026 22:18:21 +0200 Subject: [PATCH] Add security headers, rate limiting, and input validation middleware - SecurityHeaders: X-Frame-Options, CSP, HSTS, X-XSS-Protection, etc. - RateLimiter: in-memory sliding window, 100 req/min per IP - ValidateSlug: alphanumeric + hyphens, 3-100 chars - SanitizeSearchQuery: strips injection-prone characters - REDIS_URL env var passthrough in docker-compose for future caching Co-Authored-By: Paperclip --- cmd/hatch/main.go | 5 ++ docker-compose.yml | 1 + internal/handler/handler.go | 115 ++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) diff --git a/cmd/hatch/main.go b/cmd/hatch/main.go index 1e0e8fee..1ef0ae5c 100644 --- a/cmd/hatch/main.go +++ b/cmd/hatch/main.go @@ -36,6 +36,11 @@ func main() { h := handler.New(repo) h.Debug = os.Getenv("HATCH_DEBUG") != "" r := chi.NewRouter() + + // Security and performance middleware + r.Use(handler.SecurityHeaders) + r.Use(handler.NewRateLimiter(100, time.Minute).Limit) + h.RegisterRoutes(r) addr := fmt.Sprintf(":%s", port) diff --git a/docker-compose.yml b/docker-compose.yml index a3743bcc..d3eb65ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: - HATCH_PORT=8080 - HATCH_DB_PATH=/data/hatch.db - HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost} + - REDIS_URL=${REDIS_URL:-} caddy: image: caddy:2-alpine diff --git a/internal/handler/handler.go b/internal/handler/handler.go index e160530c..89c24add 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -5,7 +5,10 @@ import ( "io" "net/http" "net/http/pprof" + "strconv" "strings" + "sync" + "time" "github.com/elfoundation/hatch/internal/face" "github.com/elfoundation/hatch/internal/store" @@ -167,3 +170,115 @@ func writeError(w http.ResponseWriter, status int, msg string) { w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } + +// --------------------------------------------------------------------------- +// Security Headers Middleware +// --------------------------------------------------------------------------- + +// SecurityHeaders adds standard security headers to all responses. +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-XSS-Protection", "1; mode=block") + w.Header().Set("Content-Security-Policy", + "default-src 'self'; "+ + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+ + "style-src 'self' 'unsafe-inline'; "+ + "img-src 'self' data: https:; "+ + "font-src 'self' data:; "+ + "connect-src 'self'; "+ + "frame-ancestors 'none';") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Permissions-Policy", + "camera=(), microphone=(), geolocation=(), payment=()") + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + w.Header().Set("Strict-Transport-Security", + "max-age=31536000; includeSubDomains; preload") + } + next.ServeHTTP(w, r) + }) +} + +// --------------------------------------------------------------------------- +// In-Memory Rate Limiter (sliding window) +// --------------------------------------------------------------------------- + +// RateLimiter provides per-IP rate limiting without external dependencies. +type RateLimiter struct { + mu sync.Mutex + windows map[string][]time.Time + limit int + window time.Duration +} + +// NewRateLimiter creates a rate limiter with the given limit per window. +func NewRateLimiter(limit int, window time.Duration) *RateLimiter { + return &RateLimiter{ + windows: make(map[string][]time.Time), + limit: limit, + window: window, + } +} + +// Limit returns chi-compatible middleware. +func (rl *RateLimiter) Limit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := getClientIP(r) + now := time.Now() + cutoff := now.Add(-rl.window) + + rl.mu.Lock() + slots := rl.windows[ip] + fresh := slots[:0] + for _, t := range slots { + if t.After(cutoff) { + fresh = append(fresh, t) + } + } + if len(fresh) >= rl.limit { + rl.windows[ip] = fresh + rl.mu.Unlock() + w.Header().Set("X-RateLimit-Limit", strconv.Itoa(rl.limit)) + w.Header().Set("X-RateLimit-Remaining", "0") + http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) + return + } + fresh = append(fresh, now) + rl.windows[ip] = fresh + rl.mu.Unlock() + + w.Header().Set("X-RateLimit-Limit", strconv.Itoa(rl.limit)) + w.Header().Set("X-RateLimit-Remaining", + strconv.Itoa(rl.limit-len(fresh))) + next.ServeHTTP(w, r) + }) +} + +// --------------------------------------------------------------------------- +// Input Validation +// --------------------------------------------------------------------------- + +// ValidateSlug checks that a slug contains only lowercase alphanumeric and hyphens. +func ValidateSlug(slug string) bool { + if len(slug) < 3 || len(slug) > 100 { + return false + } + for _, c := range slug { + if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') { + return false + } + } + return true +} + +// SanitizeSearchQuery strips characters that could cause injection issues. +func SanitizeSearchQuery(q string) string { + q = strings.ReplaceAll(q, "<", "") + q = strings.ReplaceAll(q, ">", "") + q = strings.ReplaceAll(q, "'", "") + q = strings.ReplaceAll(q, "\"", "") + q = strings.ReplaceAll(q, ";", "") + q = strings.ReplaceAll(q, "--", "") + return q +}