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
+94
View File
@@ -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 == "" {
+227
View File
@@ -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")
}
}
@@ -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)
@@ -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")
}
}
@@ -742,6 +742,10 @@ func (s *UptimeService) checkMonitor(monitor models.UptimeMonitor) {
security.WithAllowLocalhost(),
security.WithAllowHTTP(),
security.WithTimeout(3*time.Second),
// Admin-configured uptime monitors may target RFC 1918 private hosts.
// Link-local (169.254.x.x), cloud metadata, and all other restricted
// ranges remain blocked at both validation layers.
security.WithAllowRFC1918(),
)
if err != nil {
msg = fmt.Sprintf("security validation failed: %s", err.Error())
@@ -756,6 +760,11 @@ func (s *UptimeService) checkMonitor(monitor models.UptimeMonitor) {
// Uptime monitors are an explicit admin-configured feature and commonly
// target loopback in local/dev setups (and in unit tests).
network.WithAllowLocalhost(),
// Mirror security.WithAllowRFC1918() above so the dial-time SSRF guard
// (Layer 2) permits the same RFC 1918 address space as URL validation
// (Layer 1). Without this, safeDialer would re-block private IPs that
// already passed URL validation, defeating the dual-layer bypass.
network.WithAllowRFC1918(),
)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -784,6 +793,10 @@ func (s *UptimeService) checkMonitor(monitor models.UptimeMonitor) {
msg = err.Error()
}
case "tcp":
// TCP monitors dial the configured host:port directly without URL validation.
// RFC 1918 addresses are intentionally permitted: TCP monitors are only created
// for RemoteServer entries, which are admin-configured and whose target is
// constructed internally from trusted fields (not raw user input).
conn, err := net.DialTimeout("tcp", monitor.URL, 10*time.Second)
if err == nil {
if closeErr := conn.Close(); closeErr != nil {
@@ -1788,3 +1788,97 @@ func TestUptimeService_UpdateMonitor_EnabledField(t *testing.T) {
assert.NoError(t, err)
assert.True(t, result.Enabled)
}
// PR-3: RFC 1918 bypass integration tests
func TestCheckMonitor_HTTP_LocalhostSucceedsWithPrivateIPBypass(t *testing.T) {
// Confirm that after the dual-layer RFC 1918 bypass is wired into
// checkMonitor, an HTTP monitor targeting the loopback interface still
// reports "up" (localhost is explicitly allowed by WithAllowLocalhost).
db := setupUptimeTestDB(t)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to start listener: %v", err)
}
addr := listener.Addr().(*net.TCPAddr)
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}),
ReadHeaderTimeout: 5 * time.Second,
}
go func() { _ = server.Serve(listener) }()
t.Cleanup(func() {
_ = server.Close()
})
// Wait for server to be ready before creating the monitor.
for i := 0; i < 20; i++ {
conn, dialErr := net.DialTimeout("tcp", addr.String(), 50*time.Millisecond)
if dialErr == nil {
_ = conn.Close()
break
}
time.Sleep(10 * time.Millisecond)
}
monitor := models.UptimeMonitor{
ID: "pr3-http-localhost-test",
Name: "HTTP Localhost RFC1918 Bypass",
Type: "http",
URL: fmt.Sprintf("http://127.0.0.1:%d", addr.Port),
Status: "pending",
Enabled: true,
}
db.Create(&monitor)
us.CheckMonitor(monitor)
var result models.UptimeMonitor
db.First(&result, "id = ?", monitor.ID)
assert.Equal(t, "up", result.Status, "HTTP monitor on localhost should be up with RFC1918 bypass")
}
func TestCheckMonitor_TCP_AcceptsRFC1918Address(t *testing.T) {
// TCP monitors bypass URL validation entirely and dial directly.
// Confirm that a TCP monitor targeting the loopback interface reports "up"
// after the RFC 1918 bypass changes.
db := setupUptimeTestDB(t)
ns := NewNotificationService(db, nil)
us := newTestUptimeService(t, db, ns)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to start TCP listener: %v", err)
}
addr := listener.Addr().(*net.TCPAddr)
go func() {
for {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
_ = conn.Close()
}
}()
t.Cleanup(func() { _ = listener.Close() })
monitor := models.UptimeMonitor{
ID: "pr3-tcp-rfc1918-test",
Name: "TCP RFC1918 Accepted",
Type: "tcp",
URL: addr.String(),
Status: "pending",
Enabled: true,
}
db.Create(&monitor)
us.CheckMonitor(monitor)
var result models.UptimeMonitor
db.First(&result, "id = ?", monitor.ID)
assert.Equal(t, "up", result.Status, "TCP monitor to loopback should report up")
}