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

## Security Hardening

This PR implements three security hardening issues:

### CSRF Protection (closes #35)

- Session-based CSRF tokens with cryptographically random 256-bit 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 webhook POSTs) and `/api` (stateless API)

### SSRF Prevention (closes #36)

- `ValidateTargetURL()` blocks private/reserved IP ranges at target creation time
- 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` in the delivery engine for defense-in-depth (prevents DNS rebinding attacks)
- Only `http` and `https` schemes allowed

### Login Rate Limiting (closes #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 every 5 minutes
- `X-Forwarded-For` and `X-Real-IP` header support for reverse proxies

### Files Changed

**New files:**
- `internal/middleware/csrf.go` + tests — CSRF middleware
- `internal/middleware/ratelimit.go` + tests — Login rate limiter
- `internal/delivery/ssrf.go` + tests — SSRF validation + safe transport

**Modified files:**
- `internal/server/routes.go` — Wire CSRF and rate limit middleware
- `internal/handlers/handlers.go` — Inject CSRF token into template data
- `internal/handlers/source_management.go` — SSRF validation on target creation
- `internal/delivery/engine.go` — SSRF-safe HTTP transport for production
- All form templates — Added hidden `csrf_token` fields
- `README.md` — Updated Security section and TODO checklist

`docker build .` passes (lint + tests + build).

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: clawbot <clawbot@eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #42
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #42.
This commit is contained in:
2026-03-17 12:38:45 +01:00
committed by Jeffrey Paul
parent 8d702a16c6
commit 60786c5019
22 changed files with 760 additions and 24 deletions

View File

@@ -0,0 +1,56 @@
package middleware
import (
"net/http"
"github.com/gorilla/csrf"
)
// CSRFToken retrieves the CSRF token from the request context.
// Returns an empty string if the gorilla/csrf middleware has not run.
func CSRFToken(r *http.Request) string {
return csrf.Token(r)
}
// CSRF returns middleware that provides CSRF protection using the
// gorilla/csrf library. The middleware uses the session authentication
// key to sign a CSRF cookie and validates a masked token submitted via
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
// token receive a 403 Forbidden response.
//
// In development mode, requests are marked as plaintext HTTP so that
// gorilla/csrf skips the strict Referer-origin check (which is only
// meaningful over TLS).
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
protect := csrf.Protect(
m.session.GetKey(),
csrf.FieldName("csrf_token"),
csrf.Secure(!m.params.Config.IsDev()),
csrf.SameSite(csrf.SameSiteLaxMode),
csrf.Path("/"),
csrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.log.Warn("csrf: token validation failed",
"method", r.Method,
"path", r.URL.Path,
"remote_addr", r.RemoteAddr,
"reason", csrf.FailureReason(r),
)
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
})),
)
// In development (plaintext HTTP), signal gorilla/csrf to skip
// the strict TLS Referer check by injecting the PlaintextHTTP
// context key before the CSRF handler sees the request.
if m.params.Config.IsDev() {
return func(next http.Handler) http.Handler {
csrfHandler := protect(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
csrfHandler.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
})
}
}
return protect
}

View File

@@ -0,0 +1,157 @@
package middleware
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
)
func TestCSRF_GETSetsToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var gotToken string
handler := m.CSRF()(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
gotToken = CSRFToken(r)
}))
req := httptest.NewRequest(http.MethodGet, "/form", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.NotEmpty(t, gotToken, "CSRF token should be set in context on GET")
}
func TestCSRF_POSTWithValidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
// Capture the token from a GET request
var token string
csrfMiddleware := m.CSRF()
getHandler := csrfMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
token = CSRFToken(r)
}))
getReq := httptest.NewRequest(http.MethodGet, "/form", nil)
getW := httptest.NewRecorder()
getHandler.ServeHTTP(getW, getReq)
cookies := getW.Result().Cookies()
require.NotEmpty(t, cookies)
require.NotEmpty(t, token)
// POST with valid token and cookies from the GET response
var called bool
postHandler := csrfMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
form := url.Values{"csrf_token": {token}}
postReq := httptest.NewRequest(http.MethodPost, "/form", strings.NewReader(form.Encode()))
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
postReq.AddCookie(c)
}
postW := httptest.NewRecorder()
postHandler.ServeHTTP(postW, postReq)
assert.True(t, called, "handler should be called with valid CSRF token")
}
func TestCSRF_POSTWithoutToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMiddleware := m.CSRF()
// GET to establish the CSRF cookie
getHandler := csrfMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
getReq := httptest.NewRequest(http.MethodGet, "/form", nil)
getW := httptest.NewRecorder()
getHandler.ServeHTTP(getW, getReq)
cookies := getW.Result().Cookies()
// POST without CSRF token
var called bool
postHandler := csrfMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
postReq := httptest.NewRequest(http.MethodPost, "/form", nil)
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
postReq.AddCookie(c)
}
postW := httptest.NewRecorder()
postHandler.ServeHTTP(postW, postReq)
assert.False(t, called, "handler should NOT be called without CSRF token")
assert.Equal(t, http.StatusForbidden, postW.Code)
}
func TestCSRF_POSTWithInvalidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMiddleware := m.CSRF()
// GET to establish the CSRF cookie
getHandler := csrfMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
getReq := httptest.NewRequest(http.MethodGet, "/form", nil)
getW := httptest.NewRecorder()
getHandler.ServeHTTP(getW, getReq)
cookies := getW.Result().Cookies()
// POST with wrong CSRF token
var called bool
postHandler := csrfMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
form := url.Values{"csrf_token": {"invalid-token-value"}}
postReq := httptest.NewRequest(http.MethodPost, "/form", strings.NewReader(form.Encode()))
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
postReq.AddCookie(c)
}
postW := httptest.NewRecorder()
postHandler.ServeHTTP(postW, postReq)
assert.False(t, called, "handler should NOT be called with invalid CSRF token")
assert.Equal(t, http.StatusForbidden, postW.Code)
}
func TestCSRF_GETDoesNotValidate(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var called bool
handler := m.CSRF()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
// GET requests should pass through without CSRF validation
req := httptest.NewRequest(http.MethodGet, "/form", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.True(t, called, "GET requests should pass through CSRF middleware")
}
func TestCSRFToken_NoMiddleware(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
assert.Empty(t, CSRFToken(req), "CSRFToken should return empty string when middleware has not run")
}

View File

@@ -40,7 +40,7 @@ func testMiddleware(t *testing.T, env string) (*Middleware, *session.Session) {
SameSite: http.SameSiteLaxMode,
}
sessManager := newTestSession(t, store, cfg, log)
sessManager := newTestSession(t, store, cfg, log, key)
m := &Middleware{
log: log,
@@ -55,9 +55,9 @@ func testMiddleware(t *testing.T, env string) (*Middleware, *session.Session) {
// newTestSession creates a session.Session with a pre-configured cookie store
// for testing. This avoids needing the fx lifecycle and database.
func newTestSession(t *testing.T, store *sessions.CookieStore, cfg *config.Config, log *slog.Logger) *session.Session {
func newTestSession(t *testing.T, store *sessions.CookieStore, cfg *config.Config, log *slog.Logger, key []byte) *session.Session {
t.Helper()
return session.NewForTest(store, cfg, log)
return session.NewForTest(store, cfg, log, key)
}
// --- Logging Middleware Tests ---
@@ -326,7 +326,7 @@ func TestMetricsAuth_ValidCredentials(t *testing.T) {
store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
sessManager := session.NewForTest(store, cfg, log)
sessManager := session.NewForTest(store, cfg, log, key)
m := &Middleware{
log: log,
@@ -366,7 +366,7 @@ func TestMetricsAuth_InvalidCredentials(t *testing.T) {
store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
sessManager := session.NewForTest(store, cfg, log)
sessManager := session.NewForTest(store, cfg, log, key)
m := &Middleware{
log: log,
@@ -406,7 +406,7 @@ func TestMetricsAuth_NoCredentials(t *testing.T) {
store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
sessManager := session.NewForTest(store, cfg, log)
sessManager := session.NewForTest(store, cfg, log, key)
m := &Middleware{
log: log,

View File

@@ -0,0 +1,48 @@
package middleware
import (
"net/http"
"time"
"github.com/go-chi/httprate"
)
const (
// loginRateLimit is the maximum number of login attempts per interval.
loginRateLimit = 5
// loginRateInterval is the time window for the rate limit.
loginRateInterval = 1 * time.Minute
)
// LoginRateLimit returns middleware that enforces per-IP rate limiting
// on login attempts using go-chi/httprate. Only POST requests are
// rate-limited; GET requests (rendering the login form) pass through
// unaffected. When the rate limit is exceeded, a 429 Too Many Requests
// response is returned. IP extraction honours X-Forwarded-For,
// X-Real-IP, and True-Client-IP headers for reverse-proxy setups.
func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
limiter := httprate.Limit(
loginRateLimit,
loginRateInterval,
httprate.WithKeyFuncs(httprate.KeyByRealIP),
httprate.WithLimitHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.log.Warn("login rate limit exceeded",
"path", r.URL.Path,
)
http.Error(w, "Too many login attempts. Please try again later.", http.StatusTooManyRequests)
})),
)
return func(next http.Handler) http.Handler {
limited := limiter(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only rate-limit POST requests (actual login attempts)
if r.Method != http.MethodPost {
next.ServeHTTP(w, r)
return
}
limited.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,90 @@
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")
}