diff --git a/internal/api/security_test.go b/internal/api/security_test.go index bd7ed88..a22a88c 100644 --- a/internal/api/security_test.go +++ b/internal/api/security_test.go @@ -81,3 +81,165 @@ func TestIsTrustedNetwork(t *testing.T) { t.Fatal("expected IP outside custom CIDR to be untrusted") } } + +func TestExtractRemoteIP(t *testing.T) { + cases := []struct { + name string + remoteAddr string + want string + }{ + // Empty input + {"empty string", "", ""}, + + // IPv4 with port + {"IPv4 with port", "192.168.1.100:54321", "192.168.1.100"}, + {"localhost with port", "127.0.0.1:8080", "127.0.0.1"}, + {"public IP with port", "203.0.113.44:443", "203.0.113.44"}, + + // IPv4 without port + {"IPv4 without port", "192.168.1.100", "192.168.1.100"}, + {"localhost without port", "127.0.0.1", "127.0.0.1"}, + + // IPv6 with port (bracketed) + {"IPv6 loopback with port", "[::1]:8080", "::1"}, + {"IPv6 full with port", "[2001:db8::1]:443", "2001:db8::1"}, + {"IPv6 link-local with port", "[fe80::1]:8080", "fe80::1"}, + + // IPv6 without port (bracketed) + {"IPv6 loopback bracketed", "[::1]", "::1"}, + {"IPv6 full bracketed", "[2001:db8::1]", "2001:db8::1"}, + + // IPv6 without brackets (raw) + {"IPv6 loopback raw", "::1", "::1"}, + {"IPv6 full raw", "2001:db8::1", "2001:db8::1"}, + + // Edge cases + {"port only", ":8080", ""}, + {"brackets only", "[]", ""}, + {"whitespace", " ", " "}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := extractRemoteIP(tc.remoteAddr) + if got != tc.want { + t.Errorf("extractRemoteIP(%q) = %q, want %q", tc.remoteAddr, got, tc.want) + } + }) + } +} + +func TestFirstValidForwardedIP(t *testing.T) { + cases := []struct { + name string + header string + want string + }{ + // Empty input + {"empty string", "", ""}, + {"whitespace only", " ", ""}, + + // Single IP + {"single IPv4", "192.168.1.100", "192.168.1.100"}, + {"single IPv4 with whitespace", " 192.168.1.100 ", "192.168.1.100"}, + {"single IPv6", "2001:db8::1", "2001:db8::1"}, + {"single IPv6 bracketed", "[2001:db8::1]", "2001:db8::1"}, + {"single IPv6 loopback", "::1", "::1"}, + + // Multiple IPs (comma-separated) + {"two IPs first valid", "192.168.1.100, 10.0.0.1", "192.168.1.100"}, + {"two IPs with spaces", " 192.168.1.100 , 10.0.0.1 ", "192.168.1.100"}, + {"three IPs", "203.0.113.1, 10.0.0.1, 172.16.0.1", "203.0.113.1"}, + + // Invalid first, valid second + {"invalid first then valid", "not-an-ip, 192.168.1.100", "192.168.1.100"}, + {"empty first then valid", ", 192.168.1.100", "192.168.1.100"}, + {"garbage then valid", "garbage, foobar, 10.0.0.1", "10.0.0.1"}, + + // All invalid + {"all invalid", "not-an-ip, also-invalid", ""}, + {"hostnames not IPs", "example.com, localhost", ""}, + + // Mixed IPv4 and IPv6 + {"IPv6 first then IPv4", "2001:db8::1, 192.168.1.1", "2001:db8::1"}, + {"IPv4 first then IPv6", "192.168.1.1, 2001:db8::1", "192.168.1.1"}, + + // Edge cases + {"IP with port rejected", "192.168.1.100:8080", ""}, + {"bracketed IPv6 with port rejected", "[2001:db8::1]:443", ""}, + {"multiple commas", "192.168.1.1,,,10.0.0.1", "192.168.1.1"}, + {"only commas", ",,,", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := firstValidForwardedIP(tc.header) + if got != tc.want { + t.Errorf("firstValidForwardedIP(%q) = %q, want %q", tc.header, got, tc.want) + } + }) + } +} + +func TestIsPrivateIPExtended(t *testing.T) { + // Extended test cases beyond the basic ones in TestIsPrivateIP + cases := []struct { + name string + ip string + want bool + }{ + // RFC 1918 private ranges + {"10.x.x.x range start", "10.0.0.0", true}, + {"10.x.x.x range middle", "10.128.64.32", true}, + {"10.x.x.x range end", "10.255.255.255", true}, + {"172.16-31.x.x start", "172.16.0.0", true}, + {"172.16-31.x.x middle", "172.24.128.64", true}, + {"172.16-31.x.x end", "172.31.255.255", true}, + {"172.15.x.x outside range", "172.15.255.255", false}, + {"172.32.x.x outside range", "172.32.0.0", false}, + {"192.168.x.x start", "192.168.0.0", true}, + {"192.168.x.x end", "192.168.255.255", true}, + {"192.169.x.x outside range", "192.169.0.0", false}, + + // Loopback + {"loopback start", "127.0.0.0", true}, + {"loopback middle", "127.128.64.32", true}, + {"loopback end", "127.255.255.255", true}, + + // IPv6 private/local + {"IPv6 loopback", "::1", true}, + {"IPv6 unique local fc00::/7 start", "fc00::1", true}, + {"IPv6 unique local fd00::", "fd00::1234", true}, + {"IPv6 link-local fe80::/10", "fe80::abcd:1234", true}, + + // Public IPs + {"Google DNS", "8.8.8.8", false}, + {"Cloudflare DNS", "1.1.1.1", false}, + {"documentation range 192.0.2.x", "192.0.2.1", false}, + {"documentation range 198.51.100.x", "198.51.100.1", false}, + {"documentation range 203.0.113.x", "203.0.113.1", false}, + {"IPv6 public", "2001:4860:4860::8888", false}, + + // With ports + {"private with port", "192.168.1.1:8080", true}, + {"public with port", "8.8.8.8:53", false}, + {"IPv6 loopback with port", "[::1]:443", true}, + {"IPv6 public with port", "[2001:4860:4860::8888]:443", false}, + + // Invalid inputs + {"empty string", "", false}, + {"invalid IP", "not-an-ip", false}, + {"hostname", "example.com", false}, + {"partial IP", "192.168", false}, + {"IPv4 with extra octet", "192.168.1.1.1", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := isPrivateIP(tc.ip) + if got != tc.want { + t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want) + } + }) + } +}