feat: add CSRF protection, SSRF prevention, and login rate limiting
All checks were successful
check / check (push) Successful in 5s

Security hardening implementing three issues:

CSRF Protection (#35):
- Session-based CSRF tokens with cryptographically random generation
- Constant-time token comparison to prevent timing attacks
- CSRF middleware applied to /pages, /sources, /source, and /user routes
- Hidden csrf_token field added to all 12+ POST forms in templates
- Excluded from /webhook (inbound) and /api (stateless) routes

SSRF Prevention (#36):
- ValidateTargetURL blocks private/reserved IP ranges at target creation
- Blocked ranges: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
  192.168.0.0/16, 169.254.0.0/16, ::1, fc00::/7, fe80::/10, plus
  multicast, reserved, test-net, and CGN ranges
- SSRF-safe HTTP transport with custom DialContext for defense-in-depth
  at delivery time (prevents DNS rebinding attacks)
- Only http/https schemes allowed

Login Rate Limiting (#37):
- Per-IP rate limiter using golang.org/x/time/rate
- 5 attempts per minute per IP on POST /pages/login
- GET requests (form rendering) pass through unaffected
- Automatic cleanup of stale per-IP limiter entries
- X-Forwarded-For and X-Real-IP header support for reverse proxies

Closes #35, closes #36, closes #37
This commit is contained in:
clawbot
2026-03-05 03:04:17 -08:00
parent a51e863017
commit 19e7557e88
18 changed files with 964 additions and 15 deletions

View File

@@ -13,6 +13,7 @@ import (
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/session"
"sneak.berlin/go/webhooker/templates"
)
@@ -128,9 +129,13 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTe
}
}
// If data is a map, merge user info into it
// Get CSRF token from request context (set by CSRF middleware)
csrfToken := middleware.CSRFToken(r)
// If data is a map, merge user info and CSRF token into it
if m, ok := data.(map[string]interface{}); ok {
m["User"] = userInfo
m["CSRFToken"] = csrfToken
if err := tmpl.Execute(w, m); err != nil {
s.log.Error("failed to execute template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -140,13 +145,15 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTe
// Wrap data with base template data
type templateDataWrapper struct {
User *UserInfo
Data interface{}
User *UserInfo
CSRFToken string
Data interface{}
}
wrapper := templateDataWrapper{
User: userInfo,
Data: data,
User: userInfo,
CSRFToken: csrfToken,
Data: data,
}
if err := tmpl.Execute(w, wrapper); err != nil {