feat: blocked_networks config and extended SSRF ranges (closes #67)
check / check (push) Successful in 2m28s
check / check (push) Successful in 2m28s
Add a blocked_networks config key: a list of CIDRs parsed with net/netip, added to (not replacing) the built-in SSRF blocklist. An invalid CIDR aborts startup naming the key and the offending value. Extend the built-in blocklist to CGNAT 100.64.0.0/10, IETF protocol assignments 192.0.0.0/24, benchmark 198.18.0.0/15, and NAT64 64:ff9b::/96, unmapping IPv4-mapped IPv6 so the IPv4 ranges are caught in both forms. Enforcement stays in the dial-time re-resolution (dialSSRFSafe), which now also consults the operator-supplied prefixes, so the DNS-rebinding window remains closed. Model: opus-4-8
This commit is contained in:
+101
-3
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -42,6 +43,7 @@ const (
|
||||
keyAllowHTTP = "allow_http"
|
||||
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
||||
keyCacheMaxBytes = "cache_max_bytes"
|
||||
keyBlockedNetworks = "blocked_networks"
|
||||
)
|
||||
|
||||
// Static validation errors. Each use site attaches the offending key
|
||||
@@ -54,6 +56,7 @@ var (
|
||||
errNotAnInteger = errors.New("not an integer")
|
||||
errNotABoolean = errors.New("not a boolean")
|
||||
errNotAStringList = errors.New("not a list of strings")
|
||||
errNotAValidCIDR = errors.New("not a valid CIDR network")
|
||||
errNotAMetricsMap = errors.New("not a map of metrics settings")
|
||||
errEmptyListEntry = errors.New("list contains an empty entry")
|
||||
errEmptyEntry = errors.New("contains an empty entry")
|
||||
@@ -100,6 +103,11 @@ type Config struct {
|
||||
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
||||
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
||||
|
||||
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
|
||||
// addition to the built-in SSRF blocklist. Enforced by the upstream
|
||||
// fetcher's dialer; the built-in ranges always apply.
|
||||
BlockedNetworks []netip.Prefix
|
||||
|
||||
// CacheMaxBytes is the disk cache size limit in bytes. Zero
|
||||
// disables the disk cache entirely. When cache_max_bytes is
|
||||
// omitted from the configuration, this holds the computed default
|
||||
@@ -168,6 +176,11 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
blockedNetworks, err := getBlockedNetworks(sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loader := &strictLoader{sc: sc}
|
||||
|
||||
c := &Config{
|
||||
@@ -183,7 +196,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
|
||||
UpstreamConnectionsPerHost: loader.intVal(
|
||||
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||
BlockedNetworks: blockedNetworks,
|
||||
}
|
||||
|
||||
// The computed default for cache_max_bytes needs a validated
|
||||
@@ -215,7 +229,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
return nil, loader.err
|
||||
}
|
||||
|
||||
err := c.validate()
|
||||
err = c.validate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -299,7 +313,7 @@ func isKnownConfigKey(key string) bool {
|
||||
switch key {
|
||||
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
||||
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
||||
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, "env":
|
||||
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks, "env":
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -778,3 +792,87 @@ func getStringSlice(sc *smartconfig.Config) []string {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getBlockedNetworks parses the blocked_networks value into CIDR prefixes,
|
||||
// or returns nil if the key is omitted. It accepts a YAML list of strings
|
||||
// or a comma-separated string. An explicitly null value, a wrong type, an
|
||||
// empty entry, a non-string entry, or an unparseable CIDR aborts startup
|
||||
// naming the key and the offending value; a default (the built-in
|
||||
// blocklist alone) applies only to an omitted key.
|
||||
func getBlockedNetworks(sc *smartconfig.Config) ([]netip.Prefix, error) {
|
||||
if sc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
raw, ok := sc.Get(keyBlockedNetworks)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if raw == nil {
|
||||
return nil, errNullConfigValue(keyBlockedNetworks)
|
||||
}
|
||||
|
||||
entries, err := blockedNetworkEntries(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prefixes := make([]netip.Prefix, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
prefix, err := netip.ParsePrefix(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config key %q: value %q is %w",
|
||||
keyBlockedNetworks, entry, errNotAValidCIDR)
|
||||
}
|
||||
|
||||
prefixes = append(prefixes, prefix)
|
||||
}
|
||||
|
||||
return prefixes, nil
|
||||
}
|
||||
|
||||
// blockedNetworkEntries extracts the raw blocked_networks entries as
|
||||
// trimmed, non-empty strings, from either a YAML list of strings or a
|
||||
// comma-separated string. Any other shape is a configuration error.
|
||||
func blockedNetworkEntries(raw any) ([]string, error) {
|
||||
switch val := raw.(type) {
|
||||
case []any:
|
||||
entries := make([]string, 0, len(val))
|
||||
|
||||
for _, item := range val {
|
||||
str, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("config key %q: list entry %v (%T) is %w",
|
||||
keyBlockedNetworks, item, item, errNotAString)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(str) == "" {
|
||||
return nil, fmt.Errorf("config key %q: %w",
|
||||
keyBlockedNetworks, errEmptyListEntry)
|
||||
}
|
||||
|
||||
entries = append(entries, strings.TrimSpace(str))
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
case string:
|
||||
entries := make([]string, 0)
|
||||
|
||||
for part := range strings.SplitSeq(val, ",") {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("config key %q: value %q %w",
|
||||
keyBlockedNetworks, val, errEmptyEntry)
|
||||
}
|
||||
|
||||
entries = append(entries, trimmed)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||
keyBlockedNetworks, raw, raw, errNotAStringList)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ func (s *Handlers) initImageService() error {
|
||||
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
||||
}
|
||||
|
||||
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
||||
|
||||
// Create the service
|
||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||
Cache: cache,
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/netip"
|
||||
neturl "net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -46,6 +47,20 @@ const (
|
||||
localhostIPv6 = "::1"
|
||||
)
|
||||
|
||||
// builtinBlockedPrefixes are internal or special-use ranges that Go's
|
||||
// net.IP predicates (IsPrivate, IsLinkLocalUnicast, and the like) do not
|
||||
// already cover. They are always blocked, in addition to any
|
||||
// operator-supplied networks. IPv4-mapped IPv6 addresses are unmapped
|
||||
// before matching, so these IPv4 ranges are caught in both forms.
|
||||
//
|
||||
//nolint:gochecknoglobals // immutable built-in blocklist
|
||||
var builtinBlockedPrefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.0/10"), // RFC 6598 CGNAT / carrier-grade NAT
|
||||
netip.MustParsePrefix("192.0.0.0/24"), // RFC 6890 IETF protocol assignments
|
||||
netip.MustParsePrefix("198.18.0.0/15"), // RFC 2544 benchmarking range
|
||||
netip.MustParsePrefix("64:ff9b::/96"), // RFC 6052 NAT64 (maps onto IPv4)
|
||||
}
|
||||
|
||||
// Fetcher errors.
|
||||
var (
|
||||
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
||||
@@ -107,6 +122,9 @@ type Config struct {
|
||||
AllowHTTP bool
|
||||
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
||||
MaxConnectionsPerHost int
|
||||
// BlockedNetworks are operator-supplied CIDR ranges refused by the
|
||||
// dialer, in addition to the always-enforced built-in ranges.
|
||||
BlockedNetworks []netip.Prefix
|
||||
}
|
||||
|
||||
// DefaultConfig returns a Config with sensible defaults.
|
||||
@@ -142,9 +160,13 @@ func New(config *Config) *HTTPFetcher {
|
||||
config = DefaultConfig()
|
||||
}
|
||||
|
||||
// Create transport with SSRF-safe dialer
|
||||
// Create transport with SSRF-safe dialer. The dialer re-resolves and
|
||||
// re-checks at connect time (closing the DNS-rebinding window) against
|
||||
// both the built-in ranges and the operator-supplied blocklist.
|
||||
transport := &http.Transport{
|
||||
DialContext: ssrfSafeDialer,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
|
||||
},
|
||||
TLSHandshakeTimeout: DefaultTLSTimeout,
|
||||
MaxIdleConns: DefaultMaxIdleConns,
|
||||
IdleConnTimeout: DefaultIdleConnTimeout,
|
||||
@@ -451,11 +473,53 @@ func isPrivateIP(ip net.IP) bool {
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
// Special-use ranges the net.IP predicates above do not cover.
|
||||
addr, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
addr = addr.Unmap()
|
||||
|
||||
return slices.ContainsFunc(builtinBlockedPrefixes, func(prefix netip.Prefix) bool {
|
||||
return prefix.Contains(addr)
|
||||
})
|
||||
}
|
||||
|
||||
// ssrfSafeDialer is a custom dialer that validates IP addresses before connecting.
|
||||
// isBlockedIP reports whether ip is refused, either by the built-in
|
||||
// internal-range check or by one of the operator-supplied prefixes.
|
||||
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
|
||||
if isPrivateIP(ip) {
|
||||
return true
|
||||
}
|
||||
|
||||
addr, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
addr = addr.Unmap()
|
||||
|
||||
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
|
||||
return prefix.Contains(addr)
|
||||
})
|
||||
}
|
||||
|
||||
// ssrfSafeDialer validates IP addresses against the built-in blocked ranges
|
||||
// before connecting. New wraps dialSSRFSafe with the operator-supplied
|
||||
// blocklist; this entry point enforces the built-in ranges alone.
|
||||
func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialSSRFSafe(ctx, network, addr, nil)
|
||||
}
|
||||
|
||||
// dialSSRFSafe re-resolves addr and refuses to connect to any built-in
|
||||
// internal range or operator-supplied blocked prefix, closing the
|
||||
// DNS-rebinding window at connect time.
|
||||
func dialSSRFSafe(
|
||||
ctx context.Context,
|
||||
network, addr string,
|
||||
blocked []netip.Prefix,
|
||||
) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -468,8 +532,10 @@ func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
}
|
||||
|
||||
// Check all resolved IPs
|
||||
if slices.ContainsFunc(ips, isPrivateIP) {
|
||||
return nil, ErrSSRFBlocked
|
||||
for _, ip := range ips {
|
||||
if isBlockedIP(ip, blocked) {
|
||||
return nil, ErrSSRFBlocked
|
||||
}
|
||||
}
|
||||
|
||||
// Connect using the first valid IP
|
||||
|
||||
Reference in New Issue
Block a user