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

@@ -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 {

View File

@@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi"
"github.com/google/uuid"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// WebhookListItem holds data for the webhook list view.
@@ -533,6 +534,17 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
http.Error(w, "URL is required for HTTP targets", http.StatusBadRequest)
return
}
// Validate URL against SSRF: block private/reserved IP ranges
if err := delivery.ValidateTargetURL(url); err != nil {
h.log.Warn("target URL blocked by SSRF protection",
"url", url,
"error", err,
)
http.Error(w, "Invalid target URL: "+err.Error(), http.StatusBadRequest)
return
}
cfg := map[string]interface{}{
"url": url,
}