diff --git a/internal/api/router_test.go b/internal/api/router_test.go new file mode 100644 index 000000000..405ebbc56 --- /dev/null +++ b/internal/api/router_test.go @@ -0,0 +1,181 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestIsDirectLoopbackRequest(t *testing.T) { + t.Helper() + + tests := []struct { + name string + req *http.Request + remoteAddr string + headers map[string]string + want bool + }{ + // Nil request + { + name: "nil request", + req: nil, + want: false, + }, + + // Valid loopback IPs without proxy headers + { + name: "loopback IPv4 without port", + remoteAddr: "127.0.0.1", + want: true, + }, + { + name: "loopback IPv4 with port", + remoteAddr: "127.0.0.1:8080", + want: true, + }, + { + name: "loopback IPv4 alternate", + remoteAddr: "127.0.0.2:54321", + want: true, + }, + { + name: "loopback IPv6 without port", + remoteAddr: "::1", + want: true, + }, + { + name: "loopback IPv6 with port", + remoteAddr: "[::1]:8080", + want: true, + }, + { + name: "loopback IPv6 with brackets no port", + remoteAddr: "[::1]", + want: true, + }, + + // Loopback with proxy headers (should reject) + { + name: "loopback with X-Forwarded-For", + remoteAddr: "127.0.0.1:8080", + headers: map[string]string{ + "X-Forwarded-For": "192.168.1.1", + }, + want: false, + }, + { + name: "loopback with Forwarded", + remoteAddr: "127.0.0.1:8080", + headers: map[string]string{ + "Forwarded": "for=192.168.1.1", + }, + want: false, + }, + { + name: "loopback with X-Real-IP", + remoteAddr: "127.0.0.1:8080", + headers: map[string]string{ + "X-Real-IP": "192.168.1.1", + }, + want: false, + }, + { + name: "loopback with multiple proxy headers", + remoteAddr: "127.0.0.1:8080", + headers: map[string]string{ + "X-Forwarded-For": "192.168.1.1", + "X-Real-IP": "10.0.0.1", + }, + want: false, + }, + { + name: "loopback IPv6 with X-Forwarded-For", + remoteAddr: "[::1]:8080", + headers: map[string]string{ + "X-Forwarded-For": "203.0.113.42", + }, + want: false, + }, + + // Non-loopback IPs (should reject) + { + name: "private IPv4", + remoteAddr: "192.168.1.1:8080", + want: false, + }, + { + name: "private IPv4 10.x", + remoteAddr: "10.0.0.1:54321", + want: false, + }, + { + name: "private IPv4 172.x", + remoteAddr: "172.16.0.1:8080", + want: false, + }, + { + name: "public IPv4", + remoteAddr: "203.0.113.42:8080", + want: false, + }, + { + name: "public IPv6", + remoteAddr: "[2001:db8::1]:8080", + want: false, + }, + { + name: "link-local IPv6", + remoteAddr: "[fe80::1]:8080", + want: false, + }, + + // Edge cases + { + name: "empty RemoteAddr", + remoteAddr: "", + want: false, + }, + { + name: "invalid IP format", + remoteAddr: "not-an-ip:8080", + want: false, + }, + { + name: "invalid IP with port", + remoteAddr: "999.999.999.999:8080", + want: false, + }, + { + name: "malformed IPv6", + remoteAddr: "[::g]:8080", + want: false, + }, + { + name: "just port", + remoteAddr: ":8080", + want: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + var req *http.Request + if tt.req != nil { + req = tt.req + } else if tt.name != "nil request" { + req = httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = tt.remoteAddr + for key, value := range tt.headers { + req.Header.Set(key, value) + } + } + + got := isDirectLoopbackRequest(req) + if got != tt.want { + t.Errorf("isDirectLoopbackRequest() = %v, want %v", got, tt.want) + } + }) + } +}