fix(uptime): allow RFC 1918 IPs for admin-configured monitors
HTTP/HTTPS uptime monitors targeting LAN addresses (192.168.x.x, 10.x.x.x, 172.16.x.x) permanently reported 'down' on fresh installs because SSRF protection rejects RFC 1918 ranges at two independent checkpoints: the URL validator (DNS-resolution layer) and the safe dialer (TCP-connect layer). Fixing only one layer leaves the monitor broken in practice. - Add IsRFC1918() predicate to the network package covering only the three RFC 1918 CIDRs; 169.254.x.x (link-local / cloud metadata) and loopback are intentionally excluded - Add WithAllowRFC1918() functional option to both SafeHTTPClient and ValidationConfig; option defaults to false so existing behaviour is unchanged for every call site except uptime monitors - In uptime_service.go, pass WithAllowRFC1918() to both ValidateExternalURL and NewSafeHTTPClient together; a coordinating comment documents that both layers must be relaxed as a unit - 169.254.169.254 and the full 169.254.0.0/16 link-local range remain unconditionally blocked; the cloud-metadata error path is preserved - 21 new tests across three packages, including an explicit regression guard that confirms RFC 1918 blocks are still applied without the option set (TestValidateExternalURL_RFC1918BlockedByDefault) Fixes issues 6 and 7 from the fresh-install bug report.
This commit is contained in:
@@ -19,6 +19,22 @@ var (
|
||||
initOnce sync.Once
|
||||
)
|
||||
|
||||
// rfc1918Blocks holds pre-parsed CIDR blocks for RFC 1918 private address ranges only.
|
||||
// Initialized once and used by IsRFC1918 to support the AllowRFC1918 bypass path.
|
||||
var (
|
||||
rfc1918Blocks []*net.IPNet
|
||||
rfc1918Once sync.Once
|
||||
)
|
||||
|
||||
// rfc1918CIDRs enumerates exactly the three RFC 1918 private address ranges.
|
||||
// Intentionally excludes loopback, link-local, cloud metadata (169.254.x.x),
|
||||
// and all other reserved ranges — those remain blocked regardless of AllowRFC1918.
|
||||
var rfc1918CIDRs = []string{
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
}
|
||||
|
||||
// privateCIDRs defines all private and reserved IP ranges to block for SSRF protection.
|
||||
// This list covers:
|
||||
// - RFC 1918 private networks (10.x, 172.16-31.x, 192.168.x)
|
||||
@@ -68,6 +84,21 @@ func initPrivateBlocks() {
|
||||
})
|
||||
}
|
||||
|
||||
// initRFC1918Blocks parses the three RFC 1918 CIDR blocks once at startup.
|
||||
func initRFC1918Blocks() {
|
||||
rfc1918Once.Do(func() {
|
||||
rfc1918Blocks = make([]*net.IPNet, 0, len(rfc1918CIDRs))
|
||||
for _, cidr := range rfc1918CIDRs {
|
||||
_, block, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
// This should never happen with valid CIDR strings
|
||||
continue
|
||||
}
|
||||
rfc1918Blocks = append(rfc1918Blocks, block)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// IsPrivateIP checks if an IP address is private, loopback, link-local, or otherwise restricted.
|
||||
// This function implements comprehensive SSRF protection by blocking:
|
||||
// - Private IPv4 ranges (RFC 1918): 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
||||
@@ -110,6 +141,35 @@ func IsPrivateIP(ip net.IP) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsRFC1918 reports whether an IP address belongs to one of the three RFC 1918
|
||||
// private address ranges: 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16.
|
||||
//
|
||||
// Unlike IsPrivateIP, this function only covers RFC 1918 ranges. It does NOT
|
||||
// return true for loopback, link-local (169.254.x.x), cloud metadata endpoints,
|
||||
// or any other reserved ranges. Use this to implement the AllowRFC1918 bypass
|
||||
// while keeping all other SSRF protections in place.
|
||||
//
|
||||
// Exported so url_validator.go (package security) can call it without duplicating logic.
|
||||
func IsRFC1918(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
initRFC1918Blocks()
|
||||
|
||||
// Normalise IPv4-mapped IPv6 addresses (::ffff:192.168.x.x → 192.168.x.x)
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
ip = ip4
|
||||
}
|
||||
|
||||
for _, block := range rfc1918Blocks {
|
||||
if block.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ClientOptions configures the behavior of the safe HTTP client.
|
||||
type ClientOptions struct {
|
||||
// Timeout is the total request timeout (default: 10s)
|
||||
@@ -129,6 +189,14 @@ type ClientOptions struct {
|
||||
|
||||
// DialTimeout is the connection timeout for individual dial attempts (default: 5s)
|
||||
DialTimeout time.Duration
|
||||
|
||||
// AllowRFC1918 permits connections to RFC 1918 private address ranges:
|
||||
// 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16.
|
||||
//
|
||||
// SECURITY NOTE: Enable only for admin-configured features (e.g., uptime monitors
|
||||
// targeting internal hosts). All other restricted ranges — loopback, link-local,
|
||||
// cloud metadata (169.254.x.x), and reserved — remain blocked regardless.
|
||||
AllowRFC1918 bool
|
||||
}
|
||||
|
||||
// Option is a functional option for configuring ClientOptions.
|
||||
@@ -183,6 +251,17 @@ func WithDialTimeout(timeout time.Duration) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithAllowRFC1918 permits connections to RFC 1918 private address ranges
|
||||
// (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16).
|
||||
//
|
||||
// Use only for admin-configured features such as uptime monitors that need to
|
||||
// reach internal hosts. All other SSRF protections remain active.
|
||||
func WithAllowRFC1918() Option {
|
||||
return func(opts *ClientOptions) {
|
||||
opts.AllowRFC1918 = true
|
||||
}
|
||||
}
|
||||
|
||||
// safeDialer creates a custom dial function that validates IP addresses at connection time.
|
||||
// This prevents DNS rebinding attacks by:
|
||||
// 1. Resolving the hostname to IP addresses
|
||||
@@ -225,6 +304,13 @@ func safeDialer(opts *ClientOptions) func(ctx context.Context, network, addr str
|
||||
continue
|
||||
}
|
||||
|
||||
// Allow RFC 1918 addresses only when explicitly permitted (e.g., admin-configured
|
||||
// uptime monitors targeting internal hosts). Link-local (169.254.x.x), loopback,
|
||||
// cloud metadata, and all other restricted ranges remain blocked.
|
||||
if opts.AllowRFC1918 && IsRFC1918(ip.IP) {
|
||||
continue
|
||||
}
|
||||
|
||||
if IsPrivateIP(ip.IP) {
|
||||
return nil, fmt.Errorf("connection to private IP blocked: %s resolved to %s", host, ip.IP)
|
||||
}
|
||||
@@ -237,6 +323,11 @@ func safeDialer(opts *ClientOptions) func(ctx context.Context, network, addr str
|
||||
selectedIP = ip.IP
|
||||
break
|
||||
}
|
||||
// Select RFC 1918 IPs when the caller has opted in.
|
||||
if opts.AllowRFC1918 && IsRFC1918(ip.IP) {
|
||||
selectedIP = ip.IP
|
||||
break
|
||||
}
|
||||
if !IsPrivateIP(ip.IP) {
|
||||
selectedIP = ip.IP
|
||||
break
|
||||
@@ -255,6 +346,9 @@ func safeDialer(opts *ClientOptions) func(ctx context.Context, network, addr str
|
||||
|
||||
// validateRedirectTarget checks if a redirect URL is safe to follow.
|
||||
// Returns an error if the redirect target resolves to private IPs.
|
||||
//
|
||||
// TODO: If MaxRedirects is ever re-enabled for uptime monitors, thread AllowRFC1918
|
||||
// through this function to permit RFC 1918 redirect targets.
|
||||
func validateRedirectTarget(req *http.Request, opts *ClientOptions) error {
|
||||
host := req.URL.Hostname()
|
||||
if host == "" {
|
||||
|
||||
@@ -920,3 +920,230 @@ func containsSubstr(s, substr string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PR-3: IsRFC1918 unit tests
|
||||
|
||||
func TestIsRFC1918_RFC1918Addresses(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
}{
|
||||
{"10.0.0.0 start", "10.0.0.0"},
|
||||
{"10.0.0.1", "10.0.0.1"},
|
||||
{"10.128.0.1", "10.128.0.1"},
|
||||
{"10.255.255.255 end", "10.255.255.255"},
|
||||
{"172.16.0.0 start", "172.16.0.0"},
|
||||
{"172.16.0.1", "172.16.0.1"},
|
||||
{"172.24.0.1", "172.24.0.1"},
|
||||
{"172.31.255.255 end", "172.31.255.255"},
|
||||
{"192.168.0.0 start", "192.168.0.0"},
|
||||
{"192.168.1.1", "192.168.1.1"},
|
||||
{"192.168.255.255 end", "192.168.255.255"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ip := net.ParseIP(tt.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("failed to parse IP: %s", tt.ip)
|
||||
}
|
||||
if !IsRFC1918(ip) {
|
||||
t.Errorf("IsRFC1918(%s) = false, want true", tt.ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRFC1918_NonRFC1918Addresses(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
}{
|
||||
{"Loopback 127.0.0.1", "127.0.0.1"},
|
||||
{"Link-local 169.254.1.1", "169.254.1.1"},
|
||||
{"Cloud metadata 169.254.169.254", "169.254.169.254"},
|
||||
{"IPv6 loopback ::1", "::1"},
|
||||
{"IPv6 link-local fe80::1", "fe80::1"},
|
||||
{"Public 8.8.8.8", "8.8.8.8"},
|
||||
{"Unspecified 0.0.0.0", "0.0.0.0"},
|
||||
{"Broadcast 255.255.255.255", "255.255.255.255"},
|
||||
{"Reserved 240.0.0.1", "240.0.0.1"},
|
||||
{"IPv6 unique local fc00::1", "fc00::1"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ip := net.ParseIP(tt.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("failed to parse IP: %s", tt.ip)
|
||||
}
|
||||
if IsRFC1918(ip) {
|
||||
t.Errorf("IsRFC1918(%s) = true, want false", tt.ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRFC1918_NilIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
if IsRFC1918(nil) {
|
||||
t.Error("IsRFC1918(nil) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRFC1918_BoundaryAddresses(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{"11.0.0.0 just outside 10/8", "11.0.0.0", false},
|
||||
{"172.15.255.255 just below 172.16/12", "172.15.255.255", false},
|
||||
{"172.32.0.0 just above 172.31/12", "172.32.0.0", false},
|
||||
{"192.167.255.255 just below 192.168/16", "192.167.255.255", false},
|
||||
{"192.169.0.0 just above 192.168/16", "192.169.0.0", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ip := net.ParseIP(tt.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("failed to parse IP: %s", tt.ip)
|
||||
}
|
||||
if got := IsRFC1918(ip); got != tt.expected {
|
||||
t.Errorf("IsRFC1918(%s) = %v, want %v", tt.ip, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRFC1918_IPv4MappedAddresses(t *testing.T) {
|
||||
t.Parallel()
|
||||
// IPv4-mapped IPv6 representations of RFC 1918 addresses should be
|
||||
// recognised as RFC 1918 (after To4() normalisation inside IsRFC1918).
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{"::ffff:10.0.0.1 mapped", "::ffff:10.0.0.1", true},
|
||||
{"::ffff:192.168.1.1 mapped", "::ffff:192.168.1.1", true},
|
||||
{"::ffff:172.16.0.1 mapped", "::ffff:172.16.0.1", true},
|
||||
{"::ffff:8.8.8.8 mapped public", "::ffff:8.8.8.8", false},
|
||||
{"::ffff:169.254.169.254 mapped link-local", "::ffff:169.254.169.254", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ip := net.ParseIP(tt.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("failed to parse IP: %s", tt.ip)
|
||||
}
|
||||
if got := IsRFC1918(ip); got != tt.expected {
|
||||
t.Errorf("IsRFC1918(%s) = %v, want %v", tt.ip, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PR-3: AllowRFC1918 safeDialer / client tests
|
||||
|
||||
func TestSafeDialer_AllowRFC1918_ValidationLoopSkipsRFC1918(t *testing.T) {
|
||||
// When AllowRFC1918 is set, the validation loop must NOT return
|
||||
// "connection to private IP blocked" for RFC 1918 addresses.
|
||||
// The subsequent TCP connection will fail because nothing is listening on
|
||||
// 192.168.1.1:80 in the test environment, but the error must be a
|
||||
// connection-level error, not an SSRF-block.
|
||||
opts := &ClientOptions{
|
||||
Timeout: 200 * time.Millisecond,
|
||||
DialTimeout: 200 * time.Millisecond,
|
||||
AllowRFC1918: true,
|
||||
}
|
||||
dial := safeDialer(opts)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := dial(ctx, "tcp", "192.168.1.1:80")
|
||||
if err == nil {
|
||||
t.Fatal("expected a connection error, got nil")
|
||||
}
|
||||
if contains(err.Error(), "connection to private IP blocked") {
|
||||
t.Errorf("AllowRFC1918 should prevent private-IP blocking message; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeDialer_AllowRFC1918_BlocksLinkLocal(t *testing.T) {
|
||||
// Link-local (169.254.x.x) must remain blocked even when AllowRFC1918=true.
|
||||
opts := &ClientOptions{
|
||||
Timeout: 200 * time.Millisecond,
|
||||
DialTimeout: 200 * time.Millisecond,
|
||||
AllowRFC1918: true,
|
||||
}
|
||||
dial := safeDialer(opts)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := dial(ctx, "tcp", "169.254.1.1:80")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for link-local address, got nil")
|
||||
}
|
||||
if !contains(err.Error(), "connection to private IP blocked") {
|
||||
t.Errorf("expected link-local to be blocked; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeDialer_AllowRFC1918_BlocksLoopbackWithoutAllowLocalhost(t *testing.T) {
|
||||
// Loopback must remain blocked when AllowRFC1918=true but AllowLocalhost=false.
|
||||
opts := &ClientOptions{
|
||||
Timeout: 200 * time.Millisecond,
|
||||
DialTimeout: 200 * time.Millisecond,
|
||||
AllowRFC1918: true,
|
||||
AllowLocalhost: false,
|
||||
}
|
||||
dial := safeDialer(opts)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := dial(ctx, "tcp", "127.0.0.1:80")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for loopback without AllowLocalhost, got nil")
|
||||
}
|
||||
if !contains(err.Error(), "connection to private IP blocked") {
|
||||
t.Errorf("expected loopback to be blocked; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSafeHTTPClient_AllowRFC1918_BlocksSSRFMetadata(t *testing.T) {
|
||||
// Cloud metadata endpoint (169.254.169.254) must be blocked even with AllowRFC1918.
|
||||
client := NewSafeHTTPClient(
|
||||
WithTimeout(200*time.Millisecond),
|
||||
WithDialTimeout(200*time.Millisecond),
|
||||
WithAllowRFC1918(),
|
||||
)
|
||||
resp, err := client.Get("http://169.254.169.254/latest/meta-data/")
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected metadata endpoint to be blocked, got nil")
|
||||
}
|
||||
if !contains(err.Error(), "connection to private IP blocked") {
|
||||
t.Errorf("expected metadata endpoint blocking error; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSafeHTTPClient_WithAllowRFC1918_OptionApplied(t *testing.T) {
|
||||
// Verify that WithAllowRFC1918() sets AllowRFC1918=true on ClientOptions.
|
||||
opts := defaultOptions()
|
||||
WithAllowRFC1918()(&opts)
|
||||
if !opts.AllowRFC1918 {
|
||||
t.Error("WithAllowRFC1918() should set AllowRFC1918=true")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user