feat: add CSRF protection, SSRF prevention, and login rate limiting (#42)
All checks were successful
check / check (push) Successful in 4s
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:
153
internal/delivery/ssrf.go
Normal file
153
internal/delivery/ssrf.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// dnsResolutionTimeout is the maximum time to wait for DNS resolution
|
||||
// during SSRF validation.
|
||||
dnsResolutionTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// blockedNetworks contains all private/reserved IP ranges that should be
|
||||
// blocked to prevent SSRF attacks. This includes RFC 1918 private
|
||||
// addresses, loopback, link-local, and IPv6 equivalents.
|
||||
//
|
||||
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||
var blockedNetworks []*net.IPNet
|
||||
|
||||
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
||||
func init() {
|
||||
cidrs := []string{
|
||||
// IPv4 private/reserved ranges
|
||||
"127.0.0.0/8", // Loopback
|
||||
"10.0.0.0/8", // RFC 1918 Class A private
|
||||
"172.16.0.0/12", // RFC 1918 Class B private
|
||||
"192.168.0.0/16", // RFC 1918 Class C private
|
||||
"169.254.0.0/16", // Link-local (cloud metadata)
|
||||
"0.0.0.0/8", // "This" network
|
||||
"100.64.0.0/10", // Shared address space (CGN)
|
||||
"192.0.0.0/24", // IETF protocol assignments
|
||||
"192.0.2.0/24", // TEST-NET-1
|
||||
"198.18.0.0/15", // Benchmarking
|
||||
"198.51.100.0/24", // TEST-NET-2
|
||||
"203.0.113.0/24", // TEST-NET-3
|
||||
"224.0.0.0/4", // Multicast
|
||||
"240.0.0.0/4", // Reserved for future use
|
||||
|
||||
// IPv6 private/reserved ranges
|
||||
"::1/128", // Loopback
|
||||
"fc00::/7", // Unique local addresses
|
||||
"fe80::/10", // Link-local
|
||||
}
|
||||
|
||||
for _, cidr := range cidrs {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("ssrf: failed to parse CIDR %q: %v", cidr, err))
|
||||
}
|
||||
blockedNetworks = append(blockedNetworks, network)
|
||||
}
|
||||
}
|
||||
|
||||
// isBlockedIP checks whether an IP address falls within any blocked
|
||||
// private/reserved network range.
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
for _, network := range blockedNetworks {
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateTargetURL checks that an HTTP delivery target URL is safe
|
||||
// from SSRF attacks. It validates the URL format, resolves the hostname
|
||||
// to IP addresses, and verifies that none of the resolved IPs are in
|
||||
// blocked private/reserved ranges.
|
||||
//
|
||||
// Returns nil if the URL is safe, or an error describing the issue.
|
||||
func ValidateTargetURL(targetURL string) error {
|
||||
parsed, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// Only allow http and https schemes
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return fmt.Errorf("unsupported URL scheme %q: only http and https are allowed", parsed.Scheme)
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("URL has no hostname")
|
||||
}
|
||||
|
||||
// Check if the host is a raw IP address first
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if isBlockedIP(ip) {
|
||||
return fmt.Errorf("target IP %s is in a blocked private/reserved range", ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve hostname to IPs and check each one
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dnsResolutionTimeout)
|
||||
defer cancel()
|
||||
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve hostname %q: %w", host, err)
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
return fmt.Errorf("hostname %q resolved to no IP addresses", host)
|
||||
}
|
||||
|
||||
for _, ipAddr := range ips {
|
||||
if isBlockedIP(ipAddr.IP) {
|
||||
return fmt.Errorf("hostname %q resolves to blocked IP %s (private/reserved range)", host, ipAddr.IP)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSSRFSafeTransport creates an http.Transport with a custom DialContext
|
||||
// that blocks connections to private/reserved IP addresses. This provides
|
||||
// defense-in-depth SSRF protection at the network layer, catching cases
|
||||
// where DNS records change between target creation and delivery time
|
||||
// (DNS rebinding attacks).
|
||||
func NewSSRFSafeTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssrf: invalid address %q: %w", addr, err)
|
||||
}
|
||||
|
||||
// Resolve hostname to IPs
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssrf: DNS resolution failed for %q: %w", host, err)
|
||||
}
|
||||
|
||||
// Check all resolved IPs
|
||||
for _, ipAddr := range ips {
|
||||
if isBlockedIP(ipAddr.IP) {
|
||||
return nil, fmt.Errorf("ssrf: connection to %s (%s) blocked — private/reserved IP range", host, ipAddr.IP)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to the first allowed IP
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user