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:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user