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:
GitHub Actions
2026-03-17 21:21:59 +00:00
parent dc9bbacc27
commit 00a18704e8
8 changed files with 1392 additions and 0 deletions

View File

@@ -120,6 +120,14 @@ type ValidationConfig struct {
MaxRedirects int
Timeout time.Duration
BlockPrivateIPs bool
// AllowRFC1918 permits addresses in the RFC 1918 private ranges
// (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16).
//
// SECURITY NOTE: Must only be set for admin-configured features such as uptime
// monitors. Link-local (169.254.x.x), loopback, cloud metadata, and all other
// restricted ranges remain blocked regardless of this flag.
AllowRFC1918 bool
}
// ValidationOption allows customizing validation behavior.
@@ -145,6 +153,15 @@ func WithMaxRedirects(maxRedirects int) ValidationOption {
return func(c *ValidationConfig) { c.MaxRedirects = maxRedirects }
}
// WithAllowRFC1918 permits addresses in the RFC 1918 private ranges
// (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16).
//
// Use only for admin-configured features (e.g., uptime monitors targeting internal hosts).
// All other SSRF protections remain active.
func WithAllowRFC1918() ValidationOption {
return func(c *ValidationConfig) { c.AllowRFC1918 = true }
}
// ValidateExternalURL validates a URL for external HTTP requests with comprehensive SSRF protection.
// This function provides defense-in-depth against Server-Side Request Forgery attacks by:
// 1. Validating URL format and scheme
@@ -272,11 +289,23 @@ func ValidateExternalURL(rawURL string, options ...ValidationOption) (string, er
if ip.To4() != nil && ip.To16() != nil && isIPv4MappedIPv6(ip) {
// Extract the IPv4 address from the mapped format
ipv4 := ip.To4()
// Allow RFC 1918 IPv4-mapped IPv6 only when the caller has explicitly opted in.
if config.AllowRFC1918 && network.IsRFC1918(ipv4) {
continue
}
if network.IsPrivateIP(ipv4) {
return "", fmt.Errorf("connection to private ip addresses is blocked for security (detected IPv4-mapped IPv6: %s)", ip.String())
}
}
// Allow RFC 1918 addresses only when the caller has explicitly opted in
// (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 regardless of this flag.
if config.AllowRFC1918 && network.IsRFC1918(ip) {
continue
}
// Check if IP is in private/reserved ranges using centralized network.IsPrivateIP
// This includes:
// - RFC 1918 private networks (10.x, 172.16.x, 192.168.x)

View File

@@ -1054,3 +1054,135 @@ func TestIsIPv4MappedIPv6_EdgeCases(t *testing.T) {
})
}
}
// PR-3: WithAllowRFC1918 validation option tests
func TestValidateExternalURL_WithAllowRFC1918_Permits10x(t *testing.T) {
t.Parallel()
_, err := ValidateExternalURL(
"http://10.0.0.1",
WithAllowHTTP(),
WithAllowRFC1918(),
WithTimeout(200*time.Millisecond),
)
// The key invariant: RFC 1918 bypass must NOT produce the blocking error.
// DNS may succeed (returning the IP) or fail (network unavailable) — both acceptable.
if err != nil && strings.Contains(err.Error(), "private ip addresses is blocked") {
t.Errorf("AllowRFC1918 should skip 10.x.x.x blocking; got: %v", err)
}
}
func TestValidateExternalURL_WithAllowRFC1918_Permits172_16x(t *testing.T) {
t.Parallel()
_, err := ValidateExternalURL(
"http://172.16.0.1",
WithAllowHTTP(),
WithAllowRFC1918(),
WithTimeout(200*time.Millisecond),
)
if err != nil && strings.Contains(err.Error(), "private ip addresses is blocked") {
t.Errorf("AllowRFC1918 should skip 172.16.x.x blocking; got: %v", err)
}
}
func TestValidateExternalURL_WithAllowRFC1918_Permits192_168x(t *testing.T) {
t.Parallel()
_, err := ValidateExternalURL(
"http://192.168.1.1",
WithAllowHTTP(),
WithAllowRFC1918(),
WithTimeout(200*time.Millisecond),
)
if err != nil && strings.Contains(err.Error(), "private ip addresses is blocked") {
t.Errorf("AllowRFC1918 should skip 192.168.x.x blocking; got: %v", err)
}
}
func TestValidateExternalURL_WithAllowRFC1918_BlocksMetadata(t *testing.T) {
t.Parallel()
// 169.254.169.254 is the cloud metadata endpoint; it must stay blocked even
// with AllowRFC1918 because 169.254.0.0/16 is not in rfc1918CIDRs.
_, err := ValidateExternalURL(
"http://169.254.169.254",
WithAllowHTTP(),
WithAllowRFC1918(),
WithTimeout(200*time.Millisecond),
)
if err == nil {
t.Fatal("expected cloud metadata endpoint to be blocked, got nil")
}
}
func TestValidateExternalURL_WithAllowRFC1918_BlocksLinkLocal(t *testing.T) {
t.Parallel()
// 169.254.1.1 is link-local but not the specific metadata IP; still blocked.
_, err := ValidateExternalURL(
"http://169.254.1.1",
WithAllowHTTP(),
WithAllowRFC1918(),
WithTimeout(200*time.Millisecond),
)
if err == nil {
t.Fatal("expected link-local address to be blocked, got nil")
}
}
func TestValidateExternalURL_WithAllowRFC1918_BlocksLoopback(t *testing.T) {
t.Parallel()
// 127.0.0.1 without WithAllowLocalhost must still be blocked.
_, err := ValidateExternalURL(
"http://127.0.0.1",
WithAllowHTTP(),
WithAllowRFC1918(),
WithTimeout(200*time.Millisecond),
)
if err == nil {
t.Fatal("expected loopback to be blocked without AllowLocalhost, got nil")
}
if !strings.Contains(err.Error(), "private ip addresses is blocked") &&
!strings.Contains(err.Error(), "dns resolution failed") {
t.Errorf("expected loopback blocking error; got: %v", err)
}
}
func TestValidateExternalURL_RFC1918BlockedByDefault(t *testing.T) {
t.Parallel()
// Without WithAllowRFC1918, RFC 1918 addresses must still fail.
_, err := ValidateExternalURL(
"http://10.0.0.1",
WithAllowHTTP(),
WithTimeout(200*time.Millisecond),
)
if err == nil {
t.Fatal("expected RFC 1918 address to be blocked by default, got nil")
}
}
func TestValidateExternalURL_WithAllowRFC1918_IPv4MappedIPv6Allowed(t *testing.T) {
t.Parallel()
// ::ffff:192.168.1.1 is an IPv4-mapped IPv6 of an RFC 1918 address.
// With AllowRFC1918, the mapped IPv4 is extracted and the RFC 1918 bypass fires.
_, err := ValidateExternalURL(
"http://[::ffff:192.168.1.1]",
WithAllowHTTP(),
WithAllowRFC1918(),
WithTimeout(200*time.Millisecond),
)
if err != nil && strings.Contains(err.Error(), "private ip addresses is blocked") {
t.Errorf("AllowRFC1918 should permit ::ffff:192.168.1.1; got: %v", err)
}
}
func TestValidateExternalURL_WithAllowRFC1918_IPv4MappedMetadataBlocked(t *testing.T) {
t.Parallel()
// ::ffff:169.254.169.254 maps to the cloud metadata IP; must stay blocked.
_, err := ValidateExternalURL(
"http://[::ffff:169.254.169.254]",
WithAllowHTTP(),
WithAllowRFC1918(),
WithTimeout(200*time.Millisecond),
)
if err == nil {
t.Fatal("expected IPv4-mapped metadata address to be blocked, got nil")
}
}