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

@@ -23,6 +23,7 @@
{{end}}
<form method="POST" action="/pages/login" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group">
<label for="username" class="label">Username</label>
<input

View File

@@ -25,6 +25,7 @@
{{.User.Username}}
</a>
<form method="POST" action="/pages/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text">Logout</button>
</form>
{{else}}
@@ -40,6 +41,7 @@
<a href="/sources" class="btn-text w-full text-left">Sources</a>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
<form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text w-full text-left">Logout</button>
</form>
{{else}}

View File

@@ -17,6 +17,7 @@
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
<form method="POST" action="/source/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-danger">Delete</button>
</form>
</div>
@@ -39,6 +40,7 @@
<!-- Add entrypoint form -->
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
<button type="submit" class="btn-primary text-sm">Add</button>
</form>
@@ -56,11 +58,13 @@
<span class="badge-error">Inactive</span>
{{end}}
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
{{if .Active}}Deactivate{{else}}Activate{{end}}
</button>
</form>
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
</form>
</div>
@@ -88,6 +92,7 @@
<!-- Add target form -->
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="flex gap-2">
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
<select name="type" x-model="targetType" class="input text-sm w-32">
@@ -125,11 +130,13 @@
<span class="badge-error">Inactive</span>
{{end}}
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
{{if .Active}}Deactivate{{else}}Activate{{end}}
</button>
</form>
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
</form>
</div>

View File

@@ -15,6 +15,7 @@
{{end}}
<form method="POST" action="/source/{{.Webhook.ID}}/edit" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group">
<label for="name" class="label">Name</label>
<input type="text" id="name" name="name" value="{{.Webhook.Name}}" required class="input">

View File

@@ -15,6 +15,7 @@
{{end}}
<form method="POST" action="/sources/new" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group">
<label for="name" class="label">Name</label>
<input type="text" id="name" name="name" required autofocus placeholder="My Webhook" class="input">