Files
webhooker/internal/middleware/ratelimit_test.go
clawbot 19e7557e88
All checks were successful
check / check (push) Successful in 5s
feat: add CSRF protection, SSRF prevention, and login rate limiting
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
2026-03-05 03:04:17 -08:00

122 lines
3.6 KiB
Go

package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/config"
)
func TestLoginRateLimit_AllowsGET(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var callCount int
handler := m.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
callCount++
w.WriteHeader(http.StatusOK)
}))
// GET requests should never be rate-limited
for i := 0; i < 20; i++ {
req := httptest.NewRequest(http.MethodGet, "/pages/login", nil)
req.RemoteAddr = "192.168.1.1:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "GET request %d should pass", i)
}
assert.Equal(t, 20, callCount)
}
func TestLoginRateLimit_LimitsPOST(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var callCount int
handler := m.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
callCount++
w.WriteHeader(http.StatusOK)
}))
// First loginRateLimit POST requests should succeed
for i := 0; i < loginRateLimit; i++ {
req := httptest.NewRequest(http.MethodPost, "/pages/login", nil)
req.RemoteAddr = "10.0.0.1:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "POST request %d should pass", i)
}
// Next POST should be rate-limited
req := httptest.NewRequest(http.MethodPost, "/pages/login", nil)
req.RemoteAddr = "10.0.0.1:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusTooManyRequests, w.Code, "POST after limit should be 429")
assert.Equal(t, loginRateLimit, callCount)
}
func TestLoginRateLimit_IndependentPerIP(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
handler := m.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Exhaust limit for IP1
for i := 0; i < loginRateLimit; i++ {
req := httptest.NewRequest(http.MethodPost, "/pages/login", nil)
req.RemoteAddr = "1.2.3.4:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
}
// IP1 should be rate-limited
req := httptest.NewRequest(http.MethodPost, "/pages/login", nil)
req.RemoteAddr = "1.2.3.4:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusTooManyRequests, w.Code)
// IP2 should still be allowed
req2 := httptest.NewRequest(http.MethodPost, "/pages/login", nil)
req2.RemoteAddr = "5.6.7.8:12345"
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code, "different IP should not be affected")
}
func TestExtractIP_RemoteAddr(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.168.1.100:54321"
assert.Equal(t, "192.168.1.100", extractIP(req))
}
func TestExtractIP_XForwardedFor(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.50, 70.41.3.18, 150.172.238.178")
assert.Equal(t, "203.0.113.50", extractIP(req))
}
func TestExtractIP_XRealIP(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:1234"
req.Header.Set("X-Real-IP", "203.0.113.50")
assert.Equal(t, "203.0.113.50", extractIP(req))
}
func TestExtractIP_XForwardedForSingle(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.50")
assert.Equal(t, "203.0.113.50", extractIP(req))
}