feat: add CSRF protection, SSRF prevention, and login rate limiting #42

Merged
sneak merged 4 commits from security/csrf-ssrf-ratelimit into main 2026-03-17 12:38:46 +01:00
Collaborator

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).

## Security Hardening This PR implements three security hardening issues: ### CSRF Protection (closes https://git.eeqj.de/sneak/webhooker/issues/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 https://git.eeqj.de/sneak/webhooker/issues/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 https://git.eeqj.de/sneak/webhooker/issues/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).
clawbot added 1 commit 2026-03-05 12:04:48 +01:00
feat: add CSRF protection, SSRF prevention, and login rate limiting
All checks were successful
check / check (push) Successful in 5s
19e7557e88
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
clawbot added the needs-review label 2026-03-05 12:05:03 +01:00
Author
Collaborator

PR ready for review. Implements three security hardening issues:

  • CSRF protection (#35): Session-based CSRF tokens on all 12+ POST forms. Applied to /pages, /sources, /source, /user routes. Excluded from /webhook and /api.
  • SSRF prevention (#36): URL validation blocking private/reserved IPs at target creation + SSRF-safe HTTP transport with custom DialContext for defense-in-depth at delivery time.
  • Login rate limiting (#37): Per-IP rate limiter (5 attempts/min) on POST /pages/login using golang.org/x/time/rate.

All existing tests pass. New tests added for all three features. docker build . passes (lint + tests + build).

PR ready for review. Implements three security hardening issues: - **CSRF protection** ([#35](https://git.eeqj.de/sneak/webhooker/issues/35)): Session-based CSRF tokens on all 12+ POST forms. Applied to `/pages`, `/sources`, `/source`, `/user` routes. Excluded from `/webhook` and `/api`. - **SSRF prevention** ([#36](https://git.eeqj.de/sneak/webhooker/issues/36)): URL validation blocking private/reserved IPs at target creation + SSRF-safe HTTP transport with custom DialContext for defense-in-depth at delivery time. - **Login rate limiting** ([#37](https://git.eeqj.de/sneak/webhooker/issues/37)): Per-IP rate limiter (5 attempts/min) on `POST /pages/login` using `golang.org/x/time/rate`. All existing tests pass. New tests added for all three features. `docker build .` passes (lint + tests + build). <!-- session: agent:sdlc-manager:subagent:817efd4a-60c7-494b-a108-720b69ef2c70 -->
clawbot removed the needs-review label 2026-03-05 12:05:54 +01:00
Author
Collaborator

Review PASS — PR #42 (CSRF + SSRF + Rate Limiting)

All three security issues verified against requirements. docker build . passes.

#35 — CSRF Protection

  • Token generation: 256-bit cryptographically random tokens via crypto/rand — correct
  • Constant-time comparison: Custom secureCompare using XOR accumulation — correct (could use crypto/subtle.ConstantTimeCompare but functionally equivalent)
  • All 12 POST forms have <input type="hidden" name="csrf_token">:
    • login.html (1 form)
    • navbar.html (2 forms — desktop + mobile logout)
    • sources_new.html (1 form)
    • source_edit.html (1 form)
    • source_detail.html (7 forms — delete, add entrypoint, toggle/delete entrypoint, add target, toggle/delete target)
  • Middleware applied to /pages, /sources, /source/{sourceID}, /user/{username} routes
  • Correctly excluded from /webhook/{uuid} (inbound webhook POSTs) and /api/v1 (stateless API)
  • Tests: GET sets token, POST with valid token passes, POST without/invalid token returns 403

#36 — SSRF Prevention

  • All required private ranges blocked: 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 0.0.0.0/8, ::1/128, fc00::/7, fe80::/10
  • Additional ranges also blocked: 100.64/10 (CGN), 192.0.0/24, 192.0.2/24 (TEST-NET-1), 198.18/15, 198.51.100/24 (TEST-NET-2), 203.0.113/24 (TEST-NET-3), 224.0.0.0/4 (multicast), 240.0.0.0/4 (reserved)
  • Two-layer defense: ValidateTargetURL checks at target creation time; NewSSRFSafeTransport with custom DialContext validates resolved IPs at connection time (DNS rebinding defense)
  • Transport correctly integrated into delivery engine HTTP client
  • Tests cover blocked/allowed IPs, blocked/allowed URLs, invalid schemes, empty hosts

#37 — Login Rate Limiting

  • Per-IP limiting using golang.org/x/time/rate: 5 attempts per minute (rate = 5/60 ≈ 0.0833/sec, burst = 5) — arithmetic correct
  • POST only: GET requests pass through unaffected — verified in code and tests
  • X-Forwarded-For support: Extracts first IP from comma-separated chain, falls back to X-Real-IP then RemoteAddr
  • Stale entry cleanup: Background goroutine prunes entries older than 10 minutes every 5 minutes — prevents unbounded memory growth
  • Tests verify: GET unlimited, POST limited after burst, independent per-IP tracking

Integrity Checks

  • No existing tests weakened or removed
  • .golangci.yml unchanged
  • docker build . (which runs make check) passes
  • Branch is up to date with main (no rebase needed)
  • README.md properly updated with security documentation and TODO checkmarks
  • New dependency golang.org/x/time v0.14.0 properly added to go.mod/go.sum
## ✅ Review PASS — [PR #42](https://git.eeqj.de/sneak/webhooker/pulls/42) (CSRF + SSRF + Rate Limiting) All three security issues verified against requirements. `docker build .` passes. ### [#35](https://git.eeqj.de/sneak/webhooker/issues/35) — CSRF Protection ✅ - **Token generation**: 256-bit cryptographically random tokens via `crypto/rand` — correct - **Constant-time comparison**: Custom `secureCompare` using XOR accumulation — correct (could use `crypto/subtle.ConstantTimeCompare` but functionally equivalent) - **All 12 POST forms** have `<input type="hidden" name="csrf_token">`: - `login.html` (1 form) - `navbar.html` (2 forms — desktop + mobile logout) - `sources_new.html` (1 form) - `source_edit.html` (1 form) - `source_detail.html` (7 forms — delete, add entrypoint, toggle/delete entrypoint, add target, toggle/delete target) - **Middleware applied** to `/pages`, `/sources`, `/source/{sourceID}`, `/user/{username}` routes - **Correctly excluded** from `/webhook/{uuid}` (inbound webhook POSTs) and `/api/v1` (stateless API) - Tests: GET sets token, POST with valid token passes, POST without/invalid token returns 403 ### [#36](https://git.eeqj.de/sneak/webhooker/issues/36) — SSRF Prevention ✅ - **All required private ranges blocked**: 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 0.0.0.0/8, ::1/128, fc00::/7, fe80::/10 - **Additional ranges** also blocked: 100.64/10 (CGN), 192.0.0/24, 192.0.2/24 (TEST-NET-1), 198.18/15, 198.51.100/24 (TEST-NET-2), 203.0.113/24 (TEST-NET-3), 224.0.0.0/4 (multicast), 240.0.0.0/4 (reserved) - **Two-layer defense**: `ValidateTargetURL` checks at target creation time; `NewSSRFSafeTransport` with custom `DialContext` validates resolved IPs at connection time (DNS rebinding defense) - Transport correctly integrated into delivery engine HTTP client - Tests cover blocked/allowed IPs, blocked/allowed URLs, invalid schemes, empty hosts ### [#37](https://git.eeqj.de/sneak/webhooker/issues/37) — Login Rate Limiting ✅ - **Per-IP limiting** using `golang.org/x/time/rate`: 5 attempts per minute (rate = 5/60 ≈ 0.0833/sec, burst = 5) — arithmetic correct - **POST only**: GET requests pass through unaffected — verified in code and tests - **X-Forwarded-For support**: Extracts first IP from comma-separated chain, falls back to X-Real-IP then RemoteAddr - **Stale entry cleanup**: Background goroutine prunes entries older than 10 minutes every 5 minutes — prevents unbounded memory growth - Tests verify: GET unlimited, POST limited after burst, independent per-IP tracking ### Integrity Checks ✅ - No existing tests weakened or removed - `.golangci.yml` unchanged - `docker build .` (which runs `make check`) passes - Branch is up to date with `main` (no rebase needed) - README.md properly updated with security documentation and TODO checkmarks - New dependency `golang.org/x/time v0.14.0` properly added to go.mod/go.sum <!-- session: agent:sdlc-manager:subagent:74181fb1-e6fa-4941-bf14-08b49ea9bae9 -->
clawbot added the merge-ready label 2026-03-05 12:09:05 +01:00
sneak was assigned by clawbot 2026-03-05 12:09:06 +01:00
Owner

@clawbot pls fix conflicts

@clawbot pls fix conflicts
clawbot removed the merge-ready label 2026-03-05 12:35:14 +01:00
clawbot force-pushed security/csrf-ssrf-ratelimit from 19e7557e88 to 7f4c40caca 2026-03-05 12:38:27 +01:00 Compare
Author
Collaborator

Rebased onto main to resolve merge conflicts from PR #41 merge.

Conflict resolution:

  • internal/server/routes.go: Merged both middleware stacks — /pages route group now applies CSRF, MaxBodySize, and LoginRateLimit. /sources and /source/{sourceID} groups retain CSRF, RequireAuth, and MaxBodySize.
  • README.md: Combined security documentation from both PRs (security headers + body size limits from PR #41, CSRF/SSRF/rate-limiting from this PR). Updated package layout, security section, and TODO checklist.

All tests pass. Docker build succeeds.

Rebased onto `main` to resolve merge conflicts from [PR #41](https://git.eeqj.de/sneak/webhooker/pulls/41) merge. **Conflict resolution:** - `internal/server/routes.go`: Merged both middleware stacks — `/pages` route group now applies CSRF, MaxBodySize, and LoginRateLimit. `/sources` and `/source/{sourceID}` groups retain CSRF, RequireAuth, and MaxBodySize. - `README.md`: Combined security documentation from both PRs (security headers + body size limits from PR #41, CSRF/SSRF/rate-limiting from this PR). Updated package layout, security section, and TODO checklist. All tests pass. Docker build succeeds. <!-- session: agent:sdlc-manager:subagent:932b81e8-1544-4beb-940f-d9ef3d60d1e7 -->
Author
Collaborator

Post-Rebase Review PASS — PR #42

Verified all three security features are intact after rebase onto main (which merged PR #41: security headers, session fixation, body limits). docker build . passes.

Middleware Stack Verification

Both PR #41 and PR #42 middleware correctly present in routes.go:

Layer Middleware Source
Global SecurityHeaders PR #41
/pages CSRF → MaxBodySize PR #42 + PR #41
/pages login group LoginRateLimit PR #42
/user/{username} CSRF PR #42
/sources CSRF → RequireAuth → MaxBodySize PR #42 + PR #41
/source/{sourceID} CSRF → RequireAuth → MaxBodySize PR #42 + PR #41
/webhook/{uuid} None (correct — external webhook traffic)

No code lost or duplicated in conflict resolution.

#35 — CSRF Protection

  • 12 POST forms across 5 templates, all 12 have csrf_token hidden input
  • Session-based 256-bit tokens with constant-time comparison
  • Applied to /pages, /user, /sources, /source routes
  • Correctly excluded from /webhook (inbound) and /api (stateless)

#36 — SSRF Prevention

  • All required ranges blocked: 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, ::1/128, fc00::/7, fe80::/10
  • Additional ranges: 0.0.0.0/8, 100.64/10 (CGN), multicast, reserved
  • DNS rebinding defense: NewSSRFSafeTransport() re-resolves hostnames at dial time and validates resolved IPs
  • Dual enforcement: URL validation at target creation (HandleTargetCreate) + transport-level blocking at delivery time (Engine.client.Transport)
  • Comprehensive tests: 142 lines covering all blocked/allowed ranges, edge cases

#37 — Login Rate Limiting

  • 5 attempts/minute per IP using golang.org/x/time/rate token bucket
  • POST-only (GET requests for login form pass through)
  • Per-IP limiter map with cleanup goroutine (10-min expiry, 5-min sweep)
  • IP extraction: X-Forwarded-For → X-Real-IP → RemoteAddr
  • Tests verify burst behavior, per-IP independence, GET bypass

Additional Checks

  • .golangci.yml: unchanged
  • No tests weakened
  • go.mod adds only golang.org/x/time (required for rate limiter)
  • README.md updated with security docs and TODO checkboxes

Minor Style Note (non-blocking)

secureCompare() in csrf.go is a hand-rolled constant-time comparison. Consider using crypto/subtle.ConstantTimeCompare from stdlib for the same guarantee with less code. Not a functional issue — the current implementation is correct.

## ✅ Post-Rebase Review PASS — [PR #42](https://git.eeqj.de/sneak/webhooker/pulls/42) Verified all three security features are intact after rebase onto main (which merged [PR #41](https://git.eeqj.de/sneak/webhooker/pulls/41): security headers, session fixation, body limits). `docker build .` passes. ### Middleware Stack Verification ✅ Both PR #41 and PR #42 middleware correctly present in `routes.go`: | Layer | Middleware | Source | |-------|-----------|--------| | Global | SecurityHeaders | PR #41 | | `/pages` | CSRF → MaxBodySize | PR #42 + PR #41 | | `/pages` login group | LoginRateLimit | PR #42 | | `/user/{username}` | CSRF | PR #42 | | `/sources` | CSRF → RequireAuth → MaxBodySize | PR #42 + PR #41 | | `/source/{sourceID}` | CSRF → RequireAuth → MaxBodySize | PR #42 + PR #41 | | `/webhook/{uuid}` | None (correct — external webhook traffic) | — | No code lost or duplicated in conflict resolution. ### [#35](https://git.eeqj.de/sneak/webhooker/issues/35) — CSRF Protection ✅ - 12 POST forms across 5 templates, all 12 have `csrf_token` hidden input - Session-based 256-bit tokens with constant-time comparison - Applied to `/pages`, `/user`, `/sources`, `/source` routes - Correctly excluded from `/webhook` (inbound) and `/api` (stateless) ### [#36](https://git.eeqj.de/sneak/webhooker/issues/36) — SSRF Prevention ✅ - All required ranges blocked: `127/8`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`, `::1/128`, `fc00::/7`, `fe80::/10` - Additional ranges: `0.0.0.0/8`, `100.64/10` (CGN), multicast, reserved - **DNS rebinding defense**: `NewSSRFSafeTransport()` re-resolves hostnames at dial time and validates resolved IPs - Dual enforcement: URL validation at target creation (`HandleTargetCreate`) + transport-level blocking at delivery time (`Engine.client.Transport`) - Comprehensive tests: 142 lines covering all blocked/allowed ranges, edge cases ### [#37](https://git.eeqj.de/sneak/webhooker/issues/37) — Login Rate Limiting ✅ - 5 attempts/minute per IP using `golang.org/x/time/rate` token bucket - POST-only (GET requests for login form pass through) - Per-IP limiter map with cleanup goroutine (10-min expiry, 5-min sweep) - IP extraction: X-Forwarded-For → X-Real-IP → RemoteAddr - Tests verify burst behavior, per-IP independence, GET bypass ### Additional Checks - `.golangci.yml`: unchanged ✅ - No tests weakened ✅ - `go.mod` adds only `golang.org/x/time` (required for rate limiter) ✅ - README.md updated with security docs and TODO checkboxes ✅ ### Minor Style Note (non-blocking) `secureCompare()` in `csrf.go` is a hand-rolled constant-time comparison. Consider using `crypto/subtle.ConstantTimeCompare` from stdlib for the same guarantee with less code. Not a functional issue — the current implementation is correct. <!-- session: agent:sdlc-manager:subagent:b50dd3c0-8dea-4aa2-8f82-045391d7c03a -->
clawbot added the merge-ready label 2026-03-05 12:42:19 +01:00
Owner

use the stdlib comparison function and remove ours

use the stdlib comparison function and remove ours
clawbot added needs-rework and removed merge-ready labels 2026-03-10 01:06:34 +01:00
sneak was unassigned by clawbot 2026-03-10 01:06:35 +01:00
clawbot self-assigned this 2026-03-10 01:06:35 +01:00
clawbot removed the needs-rework label 2026-03-10 10:36:30 +01:00
clawbot added 1 commit 2026-03-10 10:40:43 +01:00
Replace custom secureCompare with crypto/subtle.ConstantTimeCompare
All checks were successful
check / check (push) Successful in 2m7s
5c69efb5bc
Remove the hand-rolled secureCompare function and use the standard
library's crypto/subtle.ConstantTimeCompare for CSRF token validation.
Remove the corresponding unit test for the deleted function; CSRF token
comparison is still covered by the integration tests.
Author
Collaborator

Replaced the custom secureCompare function with crypto/subtle.ConstantTimeCompare() from the standard library, as requested. Removed the custom implementation and its dedicated unit test (CSRF token comparison is still fully covered by the integration tests). docker build . passes.

Replaced the custom `secureCompare` function with `crypto/subtle.ConstantTimeCompare()` from the standard library, as requested. Removed the custom implementation and its dedicated unit test (CSRF token comparison is still fully covered by the integration tests). `docker build .` passes. <!-- session: agent:sdlc-manager:subagent:8a5c9a89-7db4-40c4-a63b-66a69a1c577f -->
clawbot added the needs-review label 2026-03-10 10:40:55 +01:00
clawbot removed their assignment 2026-03-10 10:41:01 +01:00
clawbot removed the needs-review label 2026-03-10 10:44:35 +01:00
Author
Collaborator

Review PASS — PR #42 (post-rework: stdlib comparison)

Verified the rework requested by sneak ("use the stdlib comparison function and remove ours") and re-verified all three security features. docker build . passes.

Rework Verification

Check Status
Custom secureCompare function removed Completely gone — grep -rn secureCompare returns nothing
crypto/subtle.ConstantTimeCompare() used csrf.go:79subtle.ConstantTimeCompare([]byte(submitted), []byte(token)) != 1
TestSecureCompare removed Was dedicated to the deleted function; not in codebase
CSRF behavior still covered 5 integration tests (GET sets token, POST valid/invalid/missing token, GET bypasses validation) + 2 unit tests

#35 — CSRF Protection

  • 256-bit tokens via crypto/rand — correct
  • crypto/subtle.ConstantTimeCompare() for constant-time comparison — correct (stdlib, as requested)
  • All 12 forms have <input type="hidden" name="csrf_token">: login (1), navbar logout (2), source_detail (7), source_edit (1), sources_new (1)
  • CSRF middleware applied to /pages, /sources, /source/{sourceID}, /user/{username} routes
  • Correctly excluded from /webhook and /api

#36 — SSRF Prevention

  • ValidateTargetURL() blocks private/reserved IPs at target creation (17 CIDR ranges including RFC 1918, loopback, link-local, CGN, multicast, IPv6 ULA/link-local)
  • NewSSRFSafeTransport() with custom DialContext re-validates resolved IPs at delivery time — prevents DNS rebinding
  • Comprehensive test coverage: TestIsBlockedIP_PrivateRanges (15 blocked + 4 allowed), TestValidateTargetURL_Blocked (10 URLs), TestValidateTargetURL_Allowed (3 URLs), plus scheme/host validation tests

#37 — Rate Limiting

  • loginRateLimit = 5, loginRateInterval = 1 * time.Minute — exactly 5/min per-IP as specified
  • Uses golang.org/x/time/rate token bucket with per-IP tracking
  • Background cleanup goroutine prevents unbounded memory growth
  • Only POST requests are rate-limited (GET for login page passes through)
  • Tests verify: GET unlimited, POST limited at exactly 5, per-IP independence

Build

docker build . passes (includes make check — fmt, lint, tests, build).

## ✅ Review PASS — [PR #42](https://git.eeqj.de/sneak/webhooker/pulls/42) (post-rework: stdlib comparison) Verified the rework requested by sneak ("use the stdlib comparison function and remove ours") and re-verified all three security features. `docker build .` passes. ### Rework Verification ✅ | Check | Status | |-------|--------| | Custom `secureCompare` function removed | ✅ Completely gone — `grep -rn secureCompare` returns nothing | | `crypto/subtle.ConstantTimeCompare()` used | ✅ `csrf.go:79` — `subtle.ConstantTimeCompare([]byte(submitted), []byte(token)) != 1` | | `TestSecureCompare` removed | ✅ Was dedicated to the deleted function; not in codebase | | CSRF behavior still covered | ✅ 5 integration tests (GET sets token, POST valid/invalid/missing token, GET bypasses validation) + 2 unit tests | ### [#35](https://git.eeqj.de/sneak/webhooker/issues/35) — CSRF Protection ✅ - 256-bit tokens via `crypto/rand` — correct - `crypto/subtle.ConstantTimeCompare()` for constant-time comparison — correct (stdlib, as requested) - All **12 forms** have `<input type="hidden" name="csrf_token">`: login (1), navbar logout (2), source_detail (7), source_edit (1), sources_new (1) - CSRF middleware applied to `/pages`, `/sources`, `/source/{sourceID}`, `/user/{username}` routes - Correctly excluded from `/webhook` and `/api` ### [#36](https://git.eeqj.de/sneak/webhooker/issues/36) — SSRF Prevention ✅ - `ValidateTargetURL()` blocks private/reserved IPs at target creation (17 CIDR ranges including RFC 1918, loopback, link-local, CGN, multicast, IPv6 ULA/link-local) - `NewSSRFSafeTransport()` with custom `DialContext` re-validates resolved IPs at delivery time — prevents DNS rebinding - Comprehensive test coverage: `TestIsBlockedIP_PrivateRanges` (15 blocked + 4 allowed), `TestValidateTargetURL_Blocked` (10 URLs), `TestValidateTargetURL_Allowed` (3 URLs), plus scheme/host validation tests ### [#37](https://git.eeqj.de/sneak/webhooker/issues/37) — Rate Limiting ✅ - `loginRateLimit = 5`, `loginRateInterval = 1 * time.Minute` — exactly 5/min per-IP as specified - Uses `golang.org/x/time/rate` token bucket with per-IP tracking - Background cleanup goroutine prevents unbounded memory growth - Only POST requests are rate-limited (GET for login page passes through) - Tests verify: GET unlimited, POST limited at exactly 5, per-IP independence ### Build ✅ `docker build .` passes (includes `make check` — fmt, lint, tests, build). <!-- session: agent:sdlc-manager:subagent:bc18c1b3-e8d3-469b-995e-aeb8f31111fe -->
clawbot added the merge-ready label 2026-03-10 10:47:24 +01:00
sneak was assigned by clawbot 2026-03-10 10:47:31 +01:00
Owner

wtf did we write our own ratelimiting and csrf middlewares? these are standard web application tasks, isn't there some off the shelf stuff we could have used here given that we're using them in completely standard ways?

wtf did we write our own ratelimiting and csrf middlewares? these are standard web application tasks, isn't there some off the shelf stuff we could have used here given that we're using them in completely standard ways?
clawbot added needs-rework and removed merge-ready labels 2026-03-10 15:35:22 +01:00
sneak was unassigned by clawbot 2026-03-10 15:35:23 +01:00
clawbot self-assigned this 2026-03-10 15:35:23 +01:00
Owner

@clawbot this stalled

@clawbot this stalled
Author
Collaborator

Acknowledged — previous rework agent stalled. Re-dispatched a new rework agent to replace custom CSRF and rate-limiting middleware with off-the-shelf Go libraries per your feedback. SSRF prevention stays custom (application-specific). Will update when complete.

Acknowledged — previous rework agent stalled. Re-dispatched a new rework agent to replace custom CSRF and rate-limiting middleware with off-the-shelf Go libraries per your feedback. SSRF prevention stays custom (application-specific). Will update when complete.
Owner

@clawbot stalled again, because i keep restarting the gateway as we add a bunch of tool calls, and it keeps killing your subagents. please restart.

@clawbot stalled again, because i keep restarting the gateway as we add a bunch of tool calls, and it keeps killing your subagents. please restart.
Author
Collaborator

Understood — gateway restarts were killing the sub-agents. Re-dispatched rework agent (e13b484d). Working on replacing custom CSRF/rate-limiting with off-the-shelf libs now.

Understood — gateway restarts were killing the sub-agents. Re-dispatched rework agent (e13b484d). Working on replacing custom CSRF/rate-limiting with off-the-shelf libs now.
clawbot added 1 commit 2026-03-10 18:07:07 +01:00
refactor: replace custom CSRF and rate-limiting with off-the-shelf libraries
All checks were successful
check / check (push) Successful in 4s
0829f9a75d
Replace custom CSRF middleware with gorilla/csrf and custom rate-limiting
middleware with go-chi/httprate, as requested in code review.

CSRF changes:
- Replace session-based CSRF tokens with gorilla/csrf cookie-based
  double-submit pattern (HMAC-authenticated cookies)
- Keep same form field name (csrf_token) for template compatibility
- Keep same route exclusions (webhook/API routes)
- In dev mode, mark requests as plaintext HTTP to skip Referer check

Rate limiting changes:
- Replace custom token-bucket rate limiter with httprate sliding-window
  counter (per-IP, 5 POST requests/min on login endpoint)
- Remove custom IP extraction (httprate.KeyByRealIP handles
  X-Forwarded-For, X-Real-IP, True-Client-IP)
- Remove custom cleanup goroutine (httprate manages its own state)

Kept as-is:
- SSRF prevention code (internal/delivery/ssrf.go) — application-specific
- CSRFToken() wrapper function — handlers unchanged

Updated README security section and architecture overview to reflect
library choices.
Author
Collaborator

Rework: Replace custom CSRF/rate-limiting with off-the-shelf libraries

Addressed the review feedback by replacing both custom middleware implementations with well-established libraries:

CSRF: gorilla/csrf v1.7.3

  • Replaced the custom session-based CSRF middleware with gorilla/csrf, the standard Go CSRF library
  • Uses cookie-based double-submit tokens with HMAC authentication (more standard than session-based approach)
  • Kept the same csrf_token form field name — no template changes needed
  • Same route exclusions: webhook and API routes are unprotected, form routes (/pages, /sources, /source, /user) are protected
  • In dev mode, requests are marked as plaintext HTTP via csrf.PlaintextHTTPRequest() to skip the strict TLS Referer check
  • middleware.CSRFToken(r) wrapper preserved so handlers are unchanged

Rate Limiting: go-chi/httprate v0.15.0

  • Replaced the custom token-bucket rate limiter (with manual IP map, cleanup goroutine, etc.) with go-chi/httprate
  • Uses httprate.KeyByRealIP for IP extraction (handles X-Forwarded-For, X-Real-IP, True-Client-IP headers)
  • Same policy: 5 POST attempts per minute per IP on the login endpoint
  • GET requests pass through unaffected (same behavior as before)
  • Removed ~130 lines of custom rate limiter code (IP map, cleanup goroutine, mutex management)

Kept as-is

  • SSRF prevention code (internal/delivery/ssrf.go) — application-specific by necessity

Other changes

  • Added Session.GetKey() method to expose the raw 32-byte auth key for gorilla/csrf cookie signing
  • Updated all tests for new library behavior
  • Updated README security section and architecture overview to reference the new libraries
  • go mod tidy run, all dependencies pinned in go.sum
  • docker build . passes clean (lint + test + build)

Net result: -191 lines of custom middleware code replaced by 2 well-established library calls.

## Rework: Replace custom CSRF/rate-limiting with off-the-shelf libraries Addressed the review feedback by replacing both custom middleware implementations with well-established libraries: ### CSRF: `gorilla/csrf` v1.7.3 - Replaced the custom session-based CSRF middleware with [gorilla/csrf](https://github.com/gorilla/csrf), the standard Go CSRF library - Uses cookie-based double-submit tokens with HMAC authentication (more standard than session-based approach) - Kept the same `csrf_token` form field name — no template changes needed - Same route exclusions: webhook and API routes are unprotected, form routes (`/pages`, `/sources`, `/source`, `/user`) are protected - In dev mode, requests are marked as plaintext HTTP via `csrf.PlaintextHTTPRequest()` to skip the strict TLS Referer check - `middleware.CSRFToken(r)` wrapper preserved so handlers are unchanged ### Rate Limiting: `go-chi/httprate` v0.15.0 - Replaced the custom token-bucket rate limiter (with manual IP map, cleanup goroutine, etc.) with [go-chi/httprate](https://github.com/go-chi/httprate) - Uses `httprate.KeyByRealIP` for IP extraction (handles `X-Forwarded-For`, `X-Real-IP`, `True-Client-IP` headers) - Same policy: 5 POST attempts per minute per IP on the login endpoint - GET requests pass through unaffected (same behavior as before) - Removed ~130 lines of custom rate limiter code (IP map, cleanup goroutine, mutex management) ### Kept as-is - SSRF prevention code (`internal/delivery/ssrf.go`) — application-specific by necessity ### Other changes - Added `Session.GetKey()` method to expose the raw 32-byte auth key for gorilla/csrf cookie signing - Updated all tests for new library behavior - Updated README security section and architecture overview to reference the new libraries - `go mod tidy` run, all dependencies pinned in `go.sum` - `docker build .` passes clean (lint + test + build) Net result: **-191 lines** of custom middleware code replaced by 2 well-established library calls.
clawbot added needs-review and removed needs-rework labels 2026-03-10 18:08:02 +01:00
Author
Collaborator

Review PASS — PR #42 (off-the-shelf library rework)

All three security features verified. Custom CSRF and rate-limiting code fully replaced with off-the-shelf libraries as requested. docker build . passes. All tests pass.


Rework Verification: Custom → Library Migration

Check Status
Custom CSRF middleware removed No secureCompare, GenerateToken, crypto/rand token gen, or validateCSRF in middleware
gorilla/csrf v1.7.3 integrated Cookie-based double-submit tokens with HMAC signing
Custom rate limiter removed No x/time/rate, rate.NewLimiter, IP maps, cleanup goroutines
go-chi/httprate v0.15.0 integrated Sliding-window per-IP limiter via httprate.KeyByRealIP
SSRF prevention code unchanged Custom ssrf.go intact (appropriate — app-specific logic)

#35 — CSRF Protection (gorilla/csrf)

  • csrf.Protect() with session auth key for cookie signing
  • csrf.FieldName("csrf_token") — matches all 12 template hidden fields
  • csrf.Secure(!m.params.Config.IsDev()) — TLS enforcement in prod, relaxed in dev
  • csrf.SameSite(csrf.SameSiteLaxMode) — correct cookie policy
  • Dev mode: csrf.PlaintextHTTPRequest() wrapping to skip Referer check on HTTP
  • Custom error handler with structured logging (method, path, remote_addr, failure reason)
  • Route coverage: /pages, /sources, /source/{sourceID}, /user/{username}
  • Correctly excluded: /webhook/{uuid} (inbound webhook POSTs), /api/v1 (stateless)
  • All 12 forms across 6 templates have csrf_token hidden inputs
  • CSRFToken(r) wrapper delegates to csrf.Token(r) — handlers unchanged

#36 — SSRF Prevention

  • ValidateTargetURL() blocks 17 CIDR ranges (RFC 1918, loopback, link-local, CGN, multicast, reserved, TEST-NETs, IPv6 ULA/link-local)
  • Scheme whitelist: only http and https
  • NewSSRFSafeTransport() with custom DialContext re-validates resolved IPs at dial time (DNS rebinding defense)
  • Dual enforcement: URL validation at target creation (HandleTargetCreate) + transport-level at delivery (Engine.client.Transport)
  • Tests: TestIsBlockedIP_PrivateRanges (24 cases), TestValidateTargetURL_Blocked (10 URLs), TestValidateTargetURL_Allowed (3 URLs), scheme/host/init tests

#37 — Login Rate Limiting (go-chi/httprate)

  • httprate.Limit(5, 1*time.Minute, httprate.WithKeyFuncs(httprate.KeyByRealIP)) — 5 POST/min/IP
  • Custom limit handler returns 429 with structured log warning
  • GET requests bypass rate limiting (form rendering unaffected)
  • Applied only to login route group via r.Use(s.mw.LoginRateLimit())
  • Tests: GET bypass (20 requests all 200), POST limiting (6th request → 429), per-IP independence

Other Checks

Check Status
Dependencies pinned by hash in go.sum gorilla/csrf, go-chi/httprate, zeebo/xxh3, klauspost/cpuid all have h1: hashes
All tests pass (go test ./...) All packages pass
docker build . passes Clean build (lint + tests + binary)
README security section updated Library names and links in architecture, security, and TODO sections
No Makefile/linter/CI modifications No changes to Makefile, .golangci.yml, or Dockerfile
Session.GetKey() for CSRF signing Exposes raw 32-byte auth key; test helper updated to pass key
go.mod go directive bump 1.23→1.24 Matches toolchain go1.24.1
## ✅ Review PASS — [PR #42](https://git.eeqj.de/sneak/webhooker/pulls/42) (off-the-shelf library rework) All three security features verified. Custom CSRF and rate-limiting code fully replaced with off-the-shelf libraries as requested. `docker build .` passes. All tests pass. --- ### Rework Verification: Custom → Library Migration ✅ | Check | Status | |-------|--------| | Custom CSRF middleware removed | ✅ No `secureCompare`, `GenerateToken`, `crypto/rand` token gen, or `validateCSRF` in middleware | | `gorilla/csrf` v1.7.3 integrated | ✅ Cookie-based double-submit tokens with HMAC signing | | Custom rate limiter removed | ✅ No `x/time/rate`, `rate.NewLimiter`, IP maps, cleanup goroutines | | `go-chi/httprate` v0.15.0 integrated | ✅ Sliding-window per-IP limiter via `httprate.KeyByRealIP` | | SSRF prevention code unchanged | ✅ Custom `ssrf.go` intact (appropriate — app-specific logic) | ### [#35](https://git.eeqj.de/sneak/webhooker/issues/35) — CSRF Protection (gorilla/csrf) ✅ - `csrf.Protect()` with session auth key for cookie signing - `csrf.FieldName("csrf_token")` — matches all 12 template hidden fields - `csrf.Secure(!m.params.Config.IsDev())` — TLS enforcement in prod, relaxed in dev - `csrf.SameSite(csrf.SameSiteLaxMode)` — correct cookie policy - Dev mode: `csrf.PlaintextHTTPRequest()` wrapping to skip Referer check on HTTP - Custom error handler with structured logging (method, path, remote_addr, failure reason) - **Route coverage**: `/pages`, `/sources`, `/source/{sourceID}`, `/user/{username}` - **Correctly excluded**: `/webhook/{uuid}` (inbound webhook POSTs), `/api/v1` (stateless) - All **12 forms** across 6 templates have `csrf_token` hidden inputs - `CSRFToken(r)` wrapper delegates to `csrf.Token(r)` — handlers unchanged ### [#36](https://git.eeqj.de/sneak/webhooker/issues/36) — SSRF Prevention ✅ - `ValidateTargetURL()` blocks 17 CIDR ranges (RFC 1918, loopback, link-local, CGN, multicast, reserved, TEST-NETs, IPv6 ULA/link-local) - Scheme whitelist: only `http` and `https` - `NewSSRFSafeTransport()` with custom `DialContext` re-validates resolved IPs at dial time (DNS rebinding defense) - Dual enforcement: URL validation at target creation (`HandleTargetCreate`) + transport-level at delivery (`Engine.client.Transport`) - Tests: `TestIsBlockedIP_PrivateRanges` (24 cases), `TestValidateTargetURL_Blocked` (10 URLs), `TestValidateTargetURL_Allowed` (3 URLs), scheme/host/init tests ### [#37](https://git.eeqj.de/sneak/webhooker/issues/37) — Login Rate Limiting (go-chi/httprate) ✅ - `httprate.Limit(5, 1*time.Minute, httprate.WithKeyFuncs(httprate.KeyByRealIP))` — 5 POST/min/IP - Custom limit handler returns 429 with structured log warning - GET requests bypass rate limiting (form rendering unaffected) - Applied only to login route group via `r.Use(s.mw.LoginRateLimit())` - Tests: GET bypass (20 requests all 200), POST limiting (6th request → 429), per-IP independence ### Other Checks ✅ | Check | Status | |-------|--------| | Dependencies pinned by hash in go.sum | ✅ gorilla/csrf, go-chi/httprate, zeebo/xxh3, klauspost/cpuid all have h1: hashes | | All tests pass (`go test ./...`) | ✅ All packages pass | | `docker build .` passes | ✅ Clean build (lint + tests + binary) | | README security section updated | ✅ Library names and links in architecture, security, and TODO sections | | No Makefile/linter/CI modifications | ✅ No changes to Makefile, .golangci.yml, or Dockerfile | | `Session.GetKey()` for CSRF signing | ✅ Exposes raw 32-byte auth key; test helper updated to pass key | | `go.mod` go directive bump 1.23→1.24 | ✅ Matches toolchain go1.24.1 |
clawbot added merge-ready and removed needs-review labels 2026-03-10 18:11:56 +01:00
clawbot removed their assignment 2026-03-10 18:11:57 +01:00
sneak was assigned by clawbot 2026-03-10 18:11:57 +01:00
sneak added 1 commit 2026-03-17 12:32:31 +01:00
Merge branch 'main' into security/csrf-ssrf-ratelimit
All checks were successful
check / check (push) Successful in 1m59s
21ffddc6c0
sneak merged commit 60786c5019 into main 2026-03-17 12:38:46 +01:00
sneak deleted branch security/csrf-ssrf-ratelimit 2026-03-17 12:38:46 +01:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#42