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 | |
| 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')
| -rw-r--r-- | services/worker/internal/configuration/configuration.go | 3 | ||||
| -rw-r--r-- | services/worker/internal/fetcher/fetcher.go | 30 | ||||
| -rw-r--r-- | services/worker/internal/fetcher/ssrf_protection.go | 7 | ||||
| -rw-r--r-- | services/worker/internal/model/feed.go | 26 | ||||
| -rw-r--r-- | services/worker/internal/pool/pool.go | 2 | ||||
| -rw-r--r-- | services/worker/internal/writer/writer.go | 23 |
6 files changed, 71 insertions, 20 deletions
diff --git a/services/worker/internal/configuration/configuration.go b/services/worker/internal/configuration/configuration.go index 84a5995..4d08196 100644 --- a/services/worker/internal/configuration/configuration.go +++ b/services/worker/internal/configuration/configuration.go @@ -15,7 +15,6 @@ type Configuration struct { QueuePollInterval time.Duration BatchSize int HealthPort int - EncryptionKey string LogLevel string LogJSON bool } @@ -33,7 +32,6 @@ func Load() (Configuration, error) { queuePollInterval := getEnvironmentDuration("QUEUE_POLL_INTERVAL", 5*time.Second) batchSize := getEnvironmentInteger("BATCH_SIZE", 50) healthPort := getEnvironmentInteger("HEALTH_PORT", 8080) - encryptionKey := os.Getenv("ENCRYPTION_KEY") logLevel := getEnvironmentString("LOG_LEVEL", "info") logJSON := getEnvironmentBoolean("LOG_JSON", false) @@ -45,7 +43,6 @@ func Load() (Configuration, error) { QueuePollInterval: queuePollInterval, BatchSize: batchSize, HealthPort: healthPort, - EncryptionKey: encryptionKey, LogLevel: logLevel, LogJSON: logJSON, }, nil 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 } } diff --git a/services/worker/internal/model/feed.go b/services/worker/internal/model/feed.go index a903af0..db7a913 100644 --- a/services/worker/internal/model/feed.go +++ b/services/worker/internal/model/feed.go @@ -5,19 +5,19 @@ import ( ) type Feed struct { - Identifier string - URL string - SiteURL *string - Title *string - FeedType *string - Visibility string - EntityTag *string - LastModified *string - LastFetchedAt *time.Time - LastFetchError *string - ConsecutiveFailures int - NextFetchAt time.Time - FetchIntervalSeconds int + Identifier string + URL string + SiteURL *string + Title *string + FeedType *string + Visibility string + EntityTag *string + LastModified *string + LastFetchedAt *time.Time + LastFetchError *string + ConsecutiveFailures int + NextFetchAt time.Time + FetchIntervalSeconds int CreatedAt time.Time UpdatedAt time.Time SubscriberUserIdentifier *string diff --git a/services/worker/internal/pool/pool.go b/services/worker/internal/pool/pool.go index 7df03e2..0576636 100644 --- a/services/worker/internal/pool/pool.go +++ b/services/worker/internal/pool/pool.go @@ -3,6 +3,7 @@ package pool import ( "context" "log/slog" + "runtime/debug" "sync" ) @@ -38,6 +39,7 @@ func (workerPool *WorkerPool) Submit(workContext context.Context, workFunction W workerPool.logger.Error( "worker panic recovered", "panic_value", recoveredPanic, + "stack_trace", string(debug.Stack()), ) } }() diff --git a/services/worker/internal/writer/writer.go b/services/worker/internal/writer/writer.go index 748deb0..fb413e0 100644 --- a/services/worker/internal/writer/writer.go +++ b/services/worker/internal/writer/writer.go @@ -3,10 +3,12 @@ package writer import ( "context" "fmt" - "github.com/Fuwn/asa-news/internal/model" - "github.com/jackc/pgx/v5/pgxpool" + "net/url" "strings" "time" + + "github.com/Fuwn/asa-news/internal/model" + "github.com/jackc/pgx/v5/pgxpool" ) type Writer struct { @@ -192,11 +194,28 @@ func (feedWriter *Writer) UpdateFeedType( return nil } +func sanitizeErrorMessage(errorMessage string) string { + sanitized := errorMessage + for _, word := range strings.Fields(errorMessage) { + if parsed, parseError := url.Parse(word); parseError == nil && parsed.User != nil { + parsed.User = nil + sanitized = strings.ReplaceAll(sanitized, word, parsed.String()) + } + } + + if len(sanitized) > 500 { + sanitized = sanitized[:500] + } + + return sanitized +} + func (feedWriter *Writer) RecordFeedError( updateContext context.Context, feedIdentifier string, errorMessage string, ) error { + errorMessage = sanitizeErrorMessage(errorMessage) currentTime := time.Now().UTC() updateQuery := ` UPDATE feeds |