refactor: use pinned golangci-lint Docker image for linting
All checks were successful
check / check (push) Successful in 1m37s
All checks were successful
check / check (push) Successful in 1m37s
Refactor Dockerfile to use a separate lint stage with a pinned golangci-lint v2.11.3 Docker image instead of installing golangci-lint via curl in the builder stage. This follows the pattern used by sneak/pixa. Changes: - Dockerfile: separate lint stage using golangci/golangci-lint:v2.11.3 (Debian-based, pinned by sha256) with COPY --from=lint dependency - Bump Go from 1.24 to 1.26.1 (golang:1.26.1-bookworm, pinned) - Bump golangci-lint from v1.64.8 to v2.11.3 - Migrate .golangci.yml from v1 to v2 format (same linters, format only) - All Docker images pinned by sha256 digest - Fix all lint issues from the v2 linter upgrade: - Add package comments to all packages - Add doc comments to all exported types, functions, and methods - Fix unchecked errors (errcheck) - Fix unused parameters (revive) - Fix gosec warnings (MaxBytesReader for form parsing) - Fix staticcheck suggestions (fmt.Fprintf instead of WriteString) - Rename DeliveryTask to Task to avoid stutter (delivery.Task) - Rename shadowed builtin 'max' parameter - Update README.md version requirements
This commit is contained in:
@@ -2,6 +2,7 @@ package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -10,14 +11,27 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// dnsResolutionTimeout is the maximum time to wait for DNS resolution
|
||||
// during SSRF validation.
|
||||
// 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.
|
||||
// Sentinel errors for SSRF validation.
|
||||
var (
|
||||
errNoHostname = errors.New("URL has no hostname")
|
||||
errNoIPs = errors.New(
|
||||
"hostname resolved to no IP addresses",
|
||||
)
|
||||
errBlockedIP = errors.New(
|
||||
"blocked private/reserved IP range",
|
||||
)
|
||||
errInvalidScheme = errors.New(
|
||||
"only http and https are allowed",
|
||||
)
|
||||
)
|
||||
|
||||
// blockedNetworks contains all private/reserved IP ranges
|
||||
// that should be blocked to prevent SSRF attacks.
|
||||
//
|
||||
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||
var blockedNetworks []*net.IPNet
|
||||
@@ -25,129 +39,184 @@ 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
|
||||
"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",
|
||||
"0.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"192.0.0.0/24",
|
||||
"192.0.2.0/24",
|
||||
"198.18.0.0/15",
|
||||
"198.51.100.0/24",
|
||||
"203.0.113.0/24",
|
||||
"224.0.0.0/4",
|
||||
"240.0.0.0/4",
|
||||
"::1/128",
|
||||
"fc00::/7",
|
||||
"fe80::/10",
|
||||
}
|
||||
|
||||
for _, cidr := range cidrs {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("ssrf: failed to parse CIDR %q: %v", cidr, err))
|
||||
panic(fmt.Sprintf(
|
||||
"ssrf: failed to parse CIDR %q: %v",
|
||||
cidr, err,
|
||||
))
|
||||
}
|
||||
blockedNetworks = append(blockedNetworks, network)
|
||||
|
||||
blockedNetworks = append(
|
||||
blockedNetworks, network,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// isBlockedIP checks whether an IP address falls within any blocked
|
||||
// private/reserved network range.
|
||||
// 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 {
|
||||
// ValidateTargetURL checks that an HTTP delivery target
|
||||
// URL is safe from SSRF attacks.
|
||||
func ValidateTargetURL(
|
||||
ctx context.Context, 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)
|
||||
err = validateScheme(parsed.Scheme)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("URL has no hostname")
|
||||
return errNoHostname
|
||||
}
|
||||
|
||||
// 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
|
||||
return checkBlockedIP(ip)
|
||||
}
|
||||
|
||||
// Resolve hostname to IPs and check each one
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dnsResolutionTimeout)
|
||||
return validateHostname(ctx, host)
|
||||
}
|
||||
|
||||
func validateScheme(scheme string) error {
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return fmt.Errorf(
|
||||
"unsupported URL scheme %q: %w",
|
||||
scheme, errInvalidScheme,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkBlockedIP(ip net.IP) error {
|
||||
if isBlockedIP(ip) {
|
||||
return fmt.Errorf(
|
||||
"target IP %s is in a blocked "+
|
||||
"private/reserved range: %w",
|
||||
ip, errBlockedIP,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHostname(
|
||||
ctx context.Context, host string,
|
||||
) error {
|
||||
dnsCtx, cancel := context.WithTimeout(
|
||||
ctx, dnsResolutionTimeout,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(
|
||||
dnsCtx, host,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve hostname %q: %w", host, err)
|
||||
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)
|
||||
return fmt.Errorf(
|
||||
"hostname %q: %w", host, errNoIPs,
|
||||
)
|
||||
}
|
||||
|
||||
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 fmt.Errorf(
|
||||
"hostname %q resolves to blocked "+
|
||||
"IP %s: %w",
|
||||
host, ipAddr.IP, errBlockedIP,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
// NewSSRFSafeTransport creates an http.Transport with a
|
||||
// custom DialContext that blocks connections to
|
||||
// private/reserved IP addresses.
|
||||
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))
|
||||
},
|
||||
DialContext: ssrfDialContext,
|
||||
}
|
||||
}
|
||||
|
||||
func ssrfDialContext(
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(
|
||||
ctx, host,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"ssrf: DNS resolution failed for %q: %w",
|
||||
host, err,
|
||||
)
|
||||
}
|
||||
|
||||
for _, ipAddr := range ips {
|
||||
if isBlockedIP(ipAddr.IP) {
|
||||
return nil, fmt.Errorf(
|
||||
"ssrf: connection to %s (%s) "+
|
||||
"blocked: %w",
|
||||
host, ipAddr.IP, errBlockedIP,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var dialer net.Dialer
|
||||
|
||||
return dialer.DialContext(
|
||||
ctx, network,
|
||||
net.JoinHostPort(ips[0].IP.String(), port),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user