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:
parent
4a24f33f17
commit
a63d2dbf62
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user