- 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>
285 lines
7.9 KiB
Go
285 lines
7.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/pprof"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/elfoundation/hatch/internal/face"
|
|
"github.com/elfoundation/hatch/internal/store"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
// Handler groups all HTTP handlers and their shared dependencies.
|
|
type Handler struct {
|
|
Repo store.Repository
|
|
Debug bool
|
|
FaceService *face.Service
|
|
}
|
|
|
|
// New creates a new Handler with the given store.
|
|
func New(repo store.Repository) *Handler {
|
|
return &Handler{Repo: repo}
|
|
}
|
|
|
|
// NewWithFaceService creates a new Handler with face search service.
|
|
func NewWithFaceService(repo store.Repository, faceService *face.Service) *Handler {
|
|
return &Handler{
|
|
Repo: repo,
|
|
FaceService: faceService,
|
|
}
|
|
}
|
|
|
|
// RegisterRoutes mounts all routes on the given chi router.
|
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
|
r.Use(middleware.Logger)
|
|
r.Use(middleware.Recoverer)
|
|
|
|
// Health check.
|
|
r.Get("/healthz", Healthz)
|
|
r.Get("/readyz", h.Readyz)
|
|
|
|
// Debug endpoints (only when enabled).
|
|
if h.Debug {
|
|
r.Route("/debug/pprof", func(r chi.Router) {
|
|
r.HandleFunc("/", pprof.Index)
|
|
r.HandleFunc("/cmdline", pprof.Cmdline)
|
|
r.HandleFunc("/profile", pprof.Profile)
|
|
r.HandleFunc("/symbol", pprof.Symbol)
|
|
r.HandleFunc("/trace", pprof.Trace)
|
|
})
|
|
}
|
|
|
|
// JSON API v1 routes.
|
|
h.RegisterV1Routes(r)
|
|
|
|
// Space management routes.
|
|
h.RegisterSpaceRoutes(r)
|
|
|
|
// Inspect page: server-rendered request list.
|
|
r.Get("/e/{endpointID}", HandleInspect(h.Repo))
|
|
|
|
// SSE stream for live updates.
|
|
r.Get("/e/{endpointID}/events", HandleSSE(h.Repo))
|
|
|
|
// Mock configuration.
|
|
r.Put("/e/{endpointID}/mock", HandleMock(h.Repo))
|
|
|
|
// Replay: one-click re-send of a captured request.
|
|
r.Post("/e/{endpointID}/requests/{requestID}/replay", HandleReplay(h.Repo))
|
|
|
|
// Capture webhook: any method on /{endpointID} (wildcard, must be last).
|
|
r.HandleFunc("/{endpointID}", h.capture)
|
|
r.HandleFunc("/{endpointID}/*", h.capture)
|
|
}
|
|
|
|
// capture handles incoming webhook captures on /{endpointID}.
|
|
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
|
eid := r.PathValue("endpointID")
|
|
if eid == "" {
|
|
writeError(w, http.StatusBadRequest, "missing endpoint ID")
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// Auto-create endpoint if it doesn't exist.
|
|
if _, err := h.Repo.GetEndpoint(ctx, eid); err != nil {
|
|
h.Repo.CreateEndpoint(ctx, eid)
|
|
}
|
|
|
|
// Collect headers as JSON.
|
|
hdr := map[string]string{}
|
|
for k, v := range r.Header {
|
|
hdr[k] = strings.Join(v, ", ")
|
|
}
|
|
hdrJSON, _ := json.Marshal(hdr)
|
|
|
|
// Read body.
|
|
var body []byte
|
|
if r.Body != nil {
|
|
body, _ = io.ReadAll(r.Body)
|
|
r.Body.Close()
|
|
}
|
|
|
|
// Store the request.
|
|
req := &store.Request{
|
|
Method: r.Method,
|
|
Path: r.URL.Path,
|
|
Headers: string(hdrJSON),
|
|
Query: r.URL.RawQuery,
|
|
Body: body,
|
|
}
|
|
if err := h.Repo.AppendRequest(ctx, eid, req); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to store request")
|
|
return
|
|
}
|
|
|
|
// Broadcast via SSE.
|
|
broadcastRequest(eid, req)
|
|
|
|
// Look up mock response for this endpoint.
|
|
mock, err := h.Repo.GetMock(ctx, eid)
|
|
if err == nil && mock != nil {
|
|
for k, v := range mock.Headers {
|
|
w.Header().Set(k, v)
|
|
}
|
|
w.WriteHeader(mock.Status)
|
|
if mock.Body != nil {
|
|
w.Write(mock.Body)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Default: return 200 OK with empty JSON.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
// Healthz returns 200 OK for liveness probes.
|
|
func Healthz(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("ok\n"))
|
|
}
|
|
|
|
// Readyz returns 200 OK if the service is ready to handle requests.
|
|
// It checks database connectivity.
|
|
func (h *Handler) Readyz(w http.ResponseWriter, r *http.Request) {
|
|
if err := h.Repo.Ping(r.Context()); err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "database not ready"})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("ok\n"))
|
|
}
|
|
|
|
// writeError writes a JSON error response.
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
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
|
|
}
|