f7008ab86b
validateurl.go is VCS-generic but imported gitea.IsBlockedIP, creating an unexpected generic→Gitea-specific dependency. Extract IsBlockedIP and its CIDR list to internal/netutil/ipcheck.go (a neutral shared package). - gitea/ipcheck.go becomes a thin forwarding wrapper (preserves API compat for callers within the gitea package) - gitea/ipcheck_test.go replaced with a forwarding smoke test; full coverage moves to internal/netutil/ipcheck_test.go - validateurl.go now imports internal/netutil directly
143 lines
4.8 KiB
Go
143 lines
4.8 KiB
Go
package netutil
|
|
|
|
import (
|
|
"net"
|
|
"testing"
|
|
)
|
|
|
|
func TestIsBlockedIP(t *testing.T) {
|
|
blocked := []struct {
|
|
name string
|
|
ip string
|
|
}{
|
|
// IPv4 loopback
|
|
{"loopback 127.0.0.1", "127.0.0.1"},
|
|
{"loopback 127.0.0.2", "127.0.0.2"},
|
|
{"loopback 127.255.255.255", "127.255.255.255"},
|
|
// IPv4 unspecified
|
|
{"unspecified 0.0.0.0", "0.0.0.0"},
|
|
{"unspecified 0.1.2.3", "0.1.2.3"},
|
|
// RFC1918
|
|
{"RFC1918 10.0.0.1", "10.0.0.1"},
|
|
{"RFC1918 10.255.255.255", "10.255.255.255"},
|
|
{"RFC1918 172.16.0.1", "172.16.0.1"},
|
|
{"RFC1918 172.31.255.255", "172.31.255.255"},
|
|
{"RFC1918 192.168.0.1", "192.168.0.1"},
|
|
{"RFC1918 192.168.255.255", "192.168.255.255"},
|
|
// Link-local (APIPA / AWS metadata)
|
|
{"link-local 169.254.0.1", "169.254.0.1"},
|
|
{"link-local 169.254.169.254", "169.254.169.254"},
|
|
// Shared address space (carrier-grade NAT)
|
|
{"CGN 100.64.0.1", "100.64.0.1"},
|
|
{"CGN 100.127.255.255", "100.127.255.255"},
|
|
// Multicast
|
|
{"multicast 224.0.0.1", "224.0.0.1"},
|
|
{"multicast 239.255.255.255", "239.255.255.255"},
|
|
// Reserved
|
|
{"reserved 240.0.0.1", "240.0.0.1"},
|
|
{"broadcast 255.255.255.255", "255.255.255.255"},
|
|
// IPv6 loopback
|
|
{"IPv6 loopback ::1", "::1"},
|
|
// IPv6 unspecified
|
|
{"IPv6 unspecified ::", "::"},
|
|
// IPv6 link-local
|
|
{"IPv6 link-local fe80::1", "fe80::1"},
|
|
{"IPv6 link-local fe80::dead:beef", "fe80::dead:beef"},
|
|
// IPv6 ULA
|
|
{"IPv6 ULA fc00::1", "fc00::1"},
|
|
{"IPv6 ULA fd00::1", "fd00::1"},
|
|
// IPv6 multicast
|
|
{"IPv6 multicast ff02::1", "ff02::1"},
|
|
}
|
|
|
|
for _, tc := range blocked {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ip := net.ParseIP(tc.ip)
|
|
if ip == nil {
|
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
|
}
|
|
if !IsBlockedIP(ip) {
|
|
t.Errorf("IsBlockedIP(%q) = false, want true", tc.ip)
|
|
}
|
|
})
|
|
}
|
|
|
|
allowed := []struct {
|
|
name string
|
|
ip string
|
|
}{
|
|
{"public 8.8.8.8", "8.8.8.8"},
|
|
{"public 1.1.1.1", "1.1.1.1"},
|
|
{"public 198.51.100.1", "198.51.100.1"}, // RFC5737 TEST-NET-2 — a documentation-only range;
|
|
// not assigned to any real host, but intentionally left unblocked here because
|
|
// it has no special routing treatment (unlike RFC1918/loopback/link-local) and
|
|
// blocking it would require tracking every RFC5737 range without meaningful
|
|
// security benefit (no server should ever listen on a TEST-NET address).
|
|
{"public 151.101.1.1", "151.101.1.1"}, // Fastly
|
|
{"public IPv6 2001:4860:4860::8888", "2001:4860:4860::8888"}, // Google DNS
|
|
{"public IPv6 2606:4700:4700::1111", "2606:4700:4700::1111"}, // Cloudflare DNS
|
|
}
|
|
|
|
for _, tc := range allowed {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ip := net.ParseIP(tc.ip)
|
|
if ip == nil {
|
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
|
}
|
|
if IsBlockedIP(ip) {
|
|
t.Errorf("IsBlockedIP(%q) = true, want false", tc.ip)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsBlockedIPv6MappedIPv4(t *testing.T) {
|
|
// ::ffff:192.168.1.1 is an IPv6-mapped IPv4 address — should be blocked as RFC1918.
|
|
// Construct it manually as a 16-byte IP.
|
|
mapped := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 192, 168, 1, 1}
|
|
if !IsBlockedIP(mapped) {
|
|
t.Errorf("IsBlockedIP(::ffff:192.168.1.1) = false, want true (IPv6-mapped IPv4 must be normalized)")
|
|
}
|
|
|
|
// ::ffff:8.8.8.8 — IPv6-mapped public IP — should be allowed.
|
|
mappedPublic := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 8, 8, 8, 8}
|
|
if IsBlockedIP(mappedPublic) {
|
|
t.Errorf("IsBlockedIP(::ffff:8.8.8.8) = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestIsBlockedIPEdgeCases(t *testing.T) {
|
|
// The boundary between RFC1918 and public ranges.
|
|
// 172.15.255.255 is NOT private (just below 172.16.0.0/12).
|
|
notPrivate := net.ParseIP("172.15.255.255")
|
|
if IsBlockedIP(notPrivate) {
|
|
t.Errorf("IsBlockedIP(172.15.255.255) = true, want false (outside 172.16.0.0/12)")
|
|
}
|
|
// 172.32.0.0 is NOT private (just above 172.31.255.255).
|
|
notPrivate2 := net.ParseIP("172.32.0.0")
|
|
if IsBlockedIP(notPrivate2) {
|
|
t.Errorf("IsBlockedIP(172.32.0.0) = true, want false (outside 172.16.0.0/12)")
|
|
}
|
|
// CGN: 100.63.255.255 is NOT in 100.64.0.0/10.
|
|
notCGN := net.ParseIP("100.63.255.255")
|
|
if IsBlockedIP(notCGN) {
|
|
t.Errorf("IsBlockedIP(100.63.255.255) = true, want false (outside 100.64.0.0/10)")
|
|
}
|
|
// CGN: 100.128.0.0 is NOT in 100.64.0.0/10.
|
|
notCGN2 := net.ParseIP("100.128.0.0")
|
|
if IsBlockedIP(notCGN2) {
|
|
t.Errorf("IsBlockedIP(100.128.0.0) = true, want false (outside 100.64.0.0/10)")
|
|
}
|
|
}
|
|
|
|
// TestBlockedCIDRsValid verifies that all entries in blockedCIDRStrings parse
|
|
// successfully. This catches programming errors in the CIDR list without
|
|
// requiring a startup panic. The init() function records parse failures in
|
|
// blockedCIDRParseErrors rather than panicking; this test makes those failures
|
|
// visible as test failures during CI.
|
|
func TestBlockedCIDRsValid(t *testing.T) {
|
|
for _, msg := range BlockedCIDRParseErrors() {
|
|
t.Errorf("CIDR parse error: %s", msg)
|
|
}
|
|
}
|