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 <noreply@paperclip.ing>
This commit is contained in:
Chris Anderson 2026-06-25 22:18:21 +02:00
parent 4a24f33f17
commit a63d2dbf62
3 changed files with 121 additions and 0 deletions

View File

@ -36,6 +36,11 @@ func main() {
h := handler.New(repo) h := handler.New(repo)
h.Debug = os.Getenv("HATCH_DEBUG") != "" h.Debug = os.Getenv("HATCH_DEBUG") != ""
r := chi.NewRouter() r := chi.NewRouter()
// Security and performance middleware
r.Use(handler.SecurityHeaders)
r.Use(handler.NewRateLimiter(100, time.Minute).Limit)
h.RegisterRoutes(r) h.RegisterRoutes(r)
addr := fmt.Sprintf(":%s", port) addr := fmt.Sprintf(":%s", port)

View File

@ -10,6 +10,7 @@ services:
- HATCH_PORT=8080 - HATCH_PORT=8080
- HATCH_DB_PATH=/data/hatch.db - HATCH_DB_PATH=/data/hatch.db
- HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost} - HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost}
- REDIS_URL=${REDIS_URL:-}
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine

View File

@ -5,7 +5,10 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/pprof" "net/http/pprof"
"strconv"
"strings" "strings"
"sync"
"time"
"github.com/elfoundation/hatch/internal/face" "github.com/elfoundation/hatch/internal/face"
"github.com/elfoundation/hatch/internal/store" "github.com/elfoundation/hatch/internal/store"
@ -167,3 +170,115 @@ func writeError(w http.ResponseWriter, status int, msg string) {
w.WriteHeader(status) w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg}) 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
}