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
@@ -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")
}