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

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