diff options
| author | Fuwn <[email protected]> | 2026-02-08 09:23:43 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-08 09:23:43 -0800 |
| commit | 56cbd35136a5a7b366835bf6c662ed068f6b5dec (patch) | |
| tree | 1a6dc83f997683341ed3476d8f38690bfe7b7114 /services/worker/internal/fetcher | |
| parent | security: sanitize HTML in marketing demo (diff) | |
| download | asa.news-56cbd35136a5a7b366835bf6c662ed068f6b5dec.tar.xz asa.news-56cbd35136a5a7b366835bf6c662ed068f6b5dec.zip | |
security: harden Go worker
- Fix SSRF TOCTOU: add custom dialer that resolves DNS and validates
IPs at connection time, preventing DNS rebinding attacks
- Handle IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) in SSRF
protection by normalizing to IPv4 before checking reserved ranges
- Sanitize feed error messages before storing: strip credentials
from URLs and truncate to 500 chars
- Remove unused EncryptionKey from configuration
- Add stack trace logging to worker panic recovery for debugging
- Run go fmt
Diffstat (limited to 'services/worker/internal/fetcher')
| -rw-r--r-- | services/worker/internal/fetcher/fetcher.go | 30 | ||||
| -rw-r--r-- | services/worker/internal/fetcher/ssrf_protection.go | 7 |
2 files changed, 35 insertions, 2 deletions
diff --git a/services/worker/internal/fetcher/fetcher.go b/services/worker/internal/fetcher/fetcher.go index bbeb622..48dcb1e 100644 --- a/services/worker/internal/fetcher/fetcher.go +++ b/services/worker/internal/fetcher/fetcher.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net" "net/http" "time" ) @@ -21,9 +22,36 @@ type Fetcher struct { } func NewFetcher(fetchTimeout time.Duration) *Fetcher { + safeDialer := &net.Dialer{ + Timeout: 10 * time.Second, + } + + safeTransport := &http.Transport{ + DialContext: func(dialContext context.Context, network, address string) (net.Conn, error) { + host, port, splitError := net.SplitHostPort(address) + if splitError != nil { + return nil, fmt.Errorf("invalid address %q: %w", address, splitError) + } + + resolvedAddresses, lookupError := net.DefaultResolver.LookupIPAddr(dialContext, host) + if lookupError != nil { + return nil, fmt.Errorf("failed to resolve %q: %w", host, lookupError) + } + + for _, resolved := range resolvedAddresses { + if isReservedAddress(resolved.IP) { + return nil, fmt.Errorf("blocked connection to reserved IP address") + } + } + + return safeDialer.DialContext(dialContext, network, net.JoinHostPort(host, port)) + }, + } + return &Fetcher{ httpClient: &http.Client{ - Timeout: fetchTimeout, + Timeout: fetchTimeout, + Transport: safeTransport, CheckRedirect: func(request *http.Request, previousRequests []*http.Request) error { if len(previousRequests) >= 5 { return fmt.Errorf("too many redirects (exceeded 5)") diff --git a/services/worker/internal/fetcher/ssrf_protection.go b/services/worker/internal/fetcher/ssrf_protection.go index e88c8d7..86c9077 100644 --- a/services/worker/internal/fetcher/ssrf_protection.go +++ b/services/worker/internal/fetcher/ssrf_protection.go @@ -22,8 +22,13 @@ var reservedNetworks = []net.IPNet{ } func isReservedAddress(ipAddress net.IP) bool { + normalizedIP := ipAddress + if ipv4 := ipAddress.To4(); ipv4 != nil { + normalizedIP = ipv4 + } + for _, network := range reservedNetworks { - if network.Contains(ipAddress) { + if network.Contains(normalizedIP) { return true } } |