fix(security): add SSRF protection for external routes
- commit
- 55bbfad1bc571c4b6bf699de61f82887d48ffd6f
- author
- bnema · · 2026-01-17 20:56 UTC
- parent
- b77f360
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +371 -7
M internal/domain/errors.go +1 -0
| @@ -62,6 +62,7 @@ var ( | |||
| 62 | 62 | ErrInvalidPath = errors.New("invalid path format") | |
| 63 | 63 | ErrInsufficientScope = errors.New("insufficient scope for operation") | |
| 64 | 64 | ErrInvalidScope = errors.New("invalid scope format") | |
| 65 | + | ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed") | |
| 65 | 66 | ||
| 66 | 67 | // Attachment errors | |
| 67 | 68 | ErrAttachmentNotFound = errors.New("attachment not found") | |
| @@ -62,6 +62,7 @@ var ( | |||||
| 62 | ErrInvalidPath = errors.New("invalid path format") | 62 | ErrInvalidPath = errors.New("invalid path format") | ||
| 63 | ErrInsufficientScope = errors.New("insufficient scope for operation") | 63 | ErrInsufficientScope = errors.New("insufficient scope for operation") | ||
| 64 | ErrInvalidScope = errors.New("invalid scope format") | 64 | ErrInvalidScope = errors.New("invalid scope format") | ||
| 65 | + | ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed") | |||
| 65 | 66 | ||||
| 66 | // Attachment errors | 67 | // Attachment errors | ||
| 67 | ErrAttachmentNotFound = errors.New("attachment not found") | 68 | ErrAttachmentNotFound = errors.New("attachment not found") | ||
M internal/usecase/proxy/service.go +18 -1
| @@ -39,6 +39,9 @@ var proxyTransport = &http.Transport{ | |||
| 39 | 39 | type Config struct { | |
| 40 | 40 | RegistryDomain string | |
| 41 | 41 | RegistryPort int | |
| 42 | + | // ExposeContainerID controls whether to add X-Container-ID header to responses. | |
| 43 | + | // Default: false (disabled for security - prevents container enumeration) | |
| 44 | + | ExposeContainerID bool | |
| 42 | 45 | } | |
| 43 | 46 | ||
| 44 | 47 | // Service implements the ProxyService interface. | |
| @@ -140,6 +143,16 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||
| 140 | 143 | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | |
| 141 | 144 | } | |
| 142 | 145 | ||
| 146 | + | // SECURITY: Validate target is not an internal/blocked network (SSRF protection) | |
| 147 | + | if err := ValidateExternalRouteTarget(host); err != nil { | |
| 148 | + | log.Warn(). | |
| 149 | + | Err(err). | |
| 150 | + | Str("host", host). | |
| 151 | + | Str("domain", domainName). | |
| 152 | + | Msg("SSRF protection: blocked external route to internal network") | |
| 153 | + | return nil, err | |
| 154 | + | } | |
| 155 | + | ||
| 143 | 156 | target := &domain.ProxyTarget{ | |
| 144 | 157 | Host: host, | |
| 145 | 158 | Port: port, | |
| @@ -312,7 +325,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target * | |||
| 312 | 325 | }, | |
| 313 | 326 | func(resp *http.Response) error { | |
| 314 | 327 | resp.Header.Set("X-Proxied-By", "Gordon") | |
| 315 | - | resp.Header.Set("X-Container-ID", target.ContainerID) | |
| 328 | + | // SECURITY: Only expose container ID if explicitly enabled | |
| 329 | + | // This prevents container enumeration attacks | |
| 330 | + | if s.config.ExposeContainerID && target.ContainerID != "" { | |
| 331 | + | resp.Header.Set("X-Container-ID", target.ContainerID) | |
| 332 | + | } | |
| 316 | 333 | return nil | |
| 317 | 334 | }, | |
| 318 | 335 | ) | |
| @@ -39,6 +39,9 @@ var proxyTransport = &http.Transport{ | |||||
| 39 | type Config struct { | 39 | type Config struct { | ||
| 40 | RegistryDomain string | 40 | RegistryDomain string | ||
| 41 | RegistryPort int | 41 | RegistryPort int | ||
| 42 | + | // ExposeContainerID controls whether to add X-Container-ID header to responses. | |||
| 43 | + | // Default: false (disabled for security - prevents container enumeration) | |||
| 44 | + | ExposeContainerID bool | |||
| 42 | } | 45 | } | ||
| 43 | 46 | ||||
| 44 | // Service implements the ProxyService interface. | 47 | // Service implements the ProxyService interface. | ||
| @@ -140,6 +143,16 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||||
| 140 | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | 143 | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | ||
| 141 | } | 144 | } | ||
| 142 | 145 | ||||
| 146 | + | // SECURITY: Validate target is not an internal/blocked network (SSRF protection) | |||
| 147 | + | if err := ValidateExternalRouteTarget(host); err != nil { | |||
| 148 | + | log.Warn(). | |||
| 149 | + | Err(err). | |||
| 150 | + | Str("host", host). | |||
| 151 | + | Str("domain", domainName). | |||
| 152 | + | Msg("SSRF protection: blocked external route to internal network") | |||
| 153 | + | return nil, err | |||
| 154 | + | } | |||
| 155 | + | ||||
| 143 | target := &domain.ProxyTarget{ | 156 | target := &domain.ProxyTarget{ | ||
| 144 | Host: host, | 157 | Host: host, | ||
| 145 | Port: port, | 158 | Port: port, | ||
| @@ -312,7 +325,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target * | |||||
| 312 | }, | 325 | }, | ||
| 313 | func(resp *http.Response) error { | 326 | func(resp *http.Response) error { | ||
| 314 | resp.Header.Set("X-Proxied-By", "Gordon") | 327 | resp.Header.Set("X-Proxied-By", "Gordon") | ||
| 315 | - | resp.Header.Set("X-Container-ID", target.ContainerID) | 328 | + | // SECURITY: Only expose container ID if explicitly enabled |
| 329 | + | // This prevents container enumeration attacks | |||
| 330 | + | if s.config.ExposeContainerID && target.ContainerID != "" { | |||
| 331 | + | resp.Header.Set("X-Container-ID", target.ContainerID) | |||
| 332 | + | } | |||
| 316 | return nil | 333 | return nil | ||
| 317 | }, | 334 | }, | ||
| 318 | ) | 335 | ) | ||
M internal/usecase/proxy/service_test.go +44 -6
| @@ -73,15 +73,15 @@ func TestService_GetTarget_ExternalRoute(t *testing.T) { | |||
| 73 | 73 | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 74 | 74 | ctx := testContext() | |
| 75 | 75 | ||
| 76 | - | // Mock external routes | |
| 76 | + | // Mock external routes - use public IP to pass SSRF check | |
| 77 | 77 | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |
| 78 | - | "reg.example.com": "localhost:5000", | |
| 78 | + | "reg.example.com": "203.0.113.10:5000", // TEST-NET-3 (RFC 5737) - documentation range | |
| 79 | 79 | }) | |
| 80 | 80 | ||
| 81 | 81 | result, err := svc.GetTarget(ctx, "reg.example.com") | |
| 82 | 82 | ||
| 83 | 83 | assert.NoError(t, err) | |
| 84 | - | assert.Equal(t, "localhost", result.Host) | |
| 84 | + | assert.Equal(t, "203.0.113.10", result.Host) | |
| 85 | 85 | assert.Equal(t, 5000, result.Port) | |
| 86 | 86 | assert.Equal(t, "", result.ContainerID) | |
| 87 | 87 | assert.Equal(t, "http", result.Scheme) | |
| @@ -95,14 +95,14 @@ func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) { | |||
| 95 | 95 | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 96 | 96 | ctx := testContext() | |
| 97 | 97 | ||
| 98 | - | // First call - should resolve external route | |
| 98 | + | // First call - should resolve external route (use public IP) | |
| 99 | 99 | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |
| 100 | - | "reg.example.com": "localhost:5000", | |
| 100 | + | "reg.example.com": "203.0.113.10:5000", | |
| 101 | 101 | }).Once() | |
| 102 | 102 | ||
| 103 | 103 | result1, err := svc.GetTarget(ctx, "reg.example.com") | |
| 104 | 104 | assert.NoError(t, err) | |
| 105 | - | assert.Equal(t, "localhost", result1.Host) | |
| 105 | + | assert.Equal(t, "203.0.113.10", result1.Host) | |
| 106 | 106 | ||
| 107 | 107 | // Second call - should return from cache (no mock call needed) | |
| 108 | 108 | result2, err := svc.GetTarget(ctx, "reg.example.com") | |
| @@ -110,6 +110,44 @@ func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) { | |||
| 110 | 110 | assert.Equal(t, result1, result2) | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | + | func TestService_GetTarget_ExternalRoute_SSRFBlocked(t *testing.T) { | |
| 114 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 115 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 116 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 117 | + | ||
| 118 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 119 | + | ctx := testContext() | |
| 120 | + | ||
| 121 | + | tests := []struct { | |
| 122 | + | name string | |
| 123 | + | target string | |
| 124 | + | }{ | |
| 125 | + | {"localhost", "localhost:5000"}, | |
| 126 | + | {"loopback IP", "127.0.0.1:5000"}, | |
| 127 | + | {"private network 10.x", "10.0.0.1:5000"}, | |
| 128 | + | {"private network 172.x", "172.16.0.1:5000"}, | |
| 129 | + | {"private network 192.168.x", "192.168.1.1:5000"}, | |
| 130 | + | {"AWS metadata", "169.254.169.254:80"}, | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | for _, tt := range tests { | |
| 134 | + | t.Run(tt.name, func(t *testing.T) { | |
| 135 | + | // Clear cache | |
| 136 | + | svc.targets = make(map[string]*domain.ProxyTarget) | |
| 137 | + | ||
| 138 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |
| 139 | + | "ssrf.example.com": tt.target, | |
| 140 | + | }).Once() | |
| 141 | + | ||
| 142 | + | result, err := svc.GetTarget(ctx, "ssrf.example.com") | |
| 143 | + | ||
| 144 | + | assert.Error(t, err) | |
| 145 | + | assert.ErrorIs(t, err, domain.ErrSSRFBlocked) | |
| 146 | + | assert.Nil(t, result) | |
| 147 | + | }) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 113 | 151 | func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) { | |
| 114 | 152 | runtime := outmocks.NewMockContainerRuntime(t) | |
| 115 | 153 | containerSvc := inmocks.NewMockContainerService(t) | |
| @@ -73,15 +73,15 @@ func TestService_GetTarget_ExternalRoute(t *testing.T) { | |||||
| 73 | svc := NewService(runtime, containerSvc, configSvc, Config{}) | 73 | svc := NewService(runtime, containerSvc, configSvc, Config{}) | ||
| 74 | ctx := testContext() | 74 | ctx := testContext() | ||
| 75 | 75 | ||||
| 76 | - | // Mock external routes | 76 | + | // Mock external routes - use public IP to pass SSRF check |
| 77 | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | 77 | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | ||
| 78 | - | "reg.example.com": "localhost:5000", | 78 | + | "reg.example.com": "203.0.113.10:5000", // TEST-NET-3 (RFC 5737) - documentation range |
| 79 | }) | 79 | }) | ||
| 80 | 80 | ||||
| 81 | result, err := svc.GetTarget(ctx, "reg.example.com") | 81 | result, err := svc.GetTarget(ctx, "reg.example.com") | ||
| 82 | 82 | ||||
| 83 | assert.NoError(t, err) | 83 | assert.NoError(t, err) | ||
| 84 | - | assert.Equal(t, "localhost", result.Host) | 84 | + | assert.Equal(t, "203.0.113.10", result.Host) |
| 85 | assert.Equal(t, 5000, result.Port) | 85 | assert.Equal(t, 5000, result.Port) | ||
| 86 | assert.Equal(t, "", result.ContainerID) | 86 | assert.Equal(t, "", result.ContainerID) | ||
| 87 | assert.Equal(t, "http", result.Scheme) | 87 | assert.Equal(t, "http", result.Scheme) | ||
| @@ -95,14 +95,14 @@ func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) { | |||||
| 95 | svc := NewService(runtime, containerSvc, configSvc, Config{}) | 95 | svc := NewService(runtime, containerSvc, configSvc, Config{}) | ||
| 96 | ctx := testContext() | 96 | ctx := testContext() | ||
| 97 | 97 | ||||
| 98 | - | // First call - should resolve external route | 98 | + | // First call - should resolve external route (use public IP) |
| 99 | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | 99 | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | ||
| 100 | - | "reg.example.com": "localhost:5000", | 100 | + | "reg.example.com": "203.0.113.10:5000", |
| 101 | }).Once() | 101 | }).Once() | ||
| 102 | 102 | ||||
| 103 | result1, err := svc.GetTarget(ctx, "reg.example.com") | 103 | result1, err := svc.GetTarget(ctx, "reg.example.com") | ||
| 104 | assert.NoError(t, err) | 104 | assert.NoError(t, err) | ||
| 105 | - | assert.Equal(t, "localhost", result1.Host) | 105 | + | assert.Equal(t, "203.0.113.10", result1.Host) |
| 106 | 106 | ||||
| 107 | // Second call - should return from cache (no mock call needed) | 107 | // Second call - should return from cache (no mock call needed) | ||
| 108 | result2, err := svc.GetTarget(ctx, "reg.example.com") | 108 | result2, err := svc.GetTarget(ctx, "reg.example.com") | ||
| @@ -110,6 +110,44 @@ func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) { | |||||
| 110 | assert.Equal(t, result1, result2) | 110 | assert.Equal(t, result1, result2) | ||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| 113 | + | func TestService_GetTarget_ExternalRoute_SSRFBlocked(t *testing.T) { | |||
| 114 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 115 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 116 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 117 | + | ||||
| 118 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |||
| 119 | + | ctx := testContext() | |||
| 120 | + | ||||
| 121 | + | tests := []struct { | |||
| 122 | + | name string | |||
| 123 | + | target string | |||
| 124 | + | }{ | |||
| 125 | + | {"localhost", "localhost:5000"}, | |||
| 126 | + | {"loopback IP", "127.0.0.1:5000"}, | |||
| 127 | + | {"private network 10.x", "10.0.0.1:5000"}, | |||
| 128 | + | {"private network 172.x", "172.16.0.1:5000"}, | |||
| 129 | + | {"private network 192.168.x", "192.168.1.1:5000"}, | |||
| 130 | + | {"AWS metadata", "169.254.169.254:80"}, | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | for _, tt := range tests { | |||
| 134 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 135 | + | // Clear cache | |||
| 136 | + | svc.targets = make(map[string]*domain.ProxyTarget) | |||
| 137 | + | ||||
| 138 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |||
| 139 | + | "ssrf.example.com": tt.target, | |||
| 140 | + | }).Once() | |||
| 141 | + | ||||
| 142 | + | result, err := svc.GetTarget(ctx, "ssrf.example.com") | |||
| 143 | + | ||||
| 144 | + | assert.Error(t, err) | |||
| 145 | + | assert.ErrorIs(t, err, domain.ErrSSRFBlocked) | |||
| 146 | + | assert.Nil(t, result) | |||
| 147 | + | }) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 113 | func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) { | 151 | func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) { | ||
| 114 | runtime := outmocks.NewMockContainerRuntime(t) | 152 | runtime := outmocks.NewMockContainerRuntime(t) | ||
| 115 | containerSvc := inmocks.NewMockContainerService(t) | 153 | containerSvc := inmocks.NewMockContainerService(t) | ||
A internal/usecase/proxy/ssrf.go +117 -0
| @@ -0,0 +1,117 @@ | |||
| 1 | + | // Package proxy implements the reverse proxy use case. | |
| 2 | + | package proxy | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "fmt" | |
| 6 | + | "net" | |
| 7 | + | "sync" | |
| 8 | + | ||
| 9 | + | "gordon/internal/domain" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // blockedCIDRStrings contains CIDR blocks that should never be proxied to. | |
| 13 | + | // This prevents SSRF attacks targeting internal services or cloud metadata endpoints. | |
| 14 | + | var blockedCIDRStrings = []string{ | |
| 15 | + | // IPv4 loopback | |
| 16 | + | "127.0.0.0/8", | |
| 17 | + | // IPv4 private networks (RFC 1918) | |
| 18 | + | "10.0.0.0/8", | |
| 19 | + | "172.16.0.0/12", | |
| 20 | + | "192.168.0.0/16", | |
| 21 | + | // IPv4 link-local (cloud metadata endpoints like AWS/GCP/Azure) | |
| 22 | + | "169.254.0.0/16", | |
| 23 | + | // IPv4 localhost alternative | |
| 24 | + | "0.0.0.0/8", | |
| 25 | + | // IPv6 loopback | |
| 26 | + | "::1/128", | |
| 27 | + | // IPv6 unique local addresses (RFC 4193) | |
| 28 | + | "fc00::/7", | |
| 29 | + | // IPv6 link-local | |
| 30 | + | "fe80::/10", | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | var ( | |
| 34 | + | blockedCIDRs []*net.IPNet | |
| 35 | + | blockedCIDRsOnce sync.Once | |
| 36 | + | ) | |
| 37 | + | ||
| 38 | + | // initBlockedCIDRs parses the blocked CIDR strings into net.IPNet. | |
| 39 | + | // Called lazily on first use. | |
| 40 | + | func initBlockedCIDRs() { | |
| 41 | + | blockedCIDRsOnce.Do(func() { | |
| 42 | + | blockedCIDRs = make([]*net.IPNet, 0, len(blockedCIDRStrings)) | |
| 43 | + | for _, cidr := range blockedCIDRStrings { | |
| 44 | + | _, network, err := net.ParseCIDR(cidr) | |
| 45 | + | if err != nil { | |
| 46 | + | // This should never happen with our hardcoded CIDRs | |
| 47 | + | panic(fmt.Sprintf("invalid blocked CIDR: %s: %v", cidr, err)) | |
| 48 | + | } | |
| 49 | + | blockedCIDRs = append(blockedCIDRs, network) | |
| 50 | + | } | |
| 51 | + | }) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // isBlockedIP checks if an IP address is in any of the blocked CIDR ranges. | |
| 55 | + | func isBlockedIP(ip net.IP) bool { | |
| 56 | + | initBlockedCIDRs() | |
| 57 | + | for _, network := range blockedCIDRs { | |
| 58 | + | if network.Contains(ip) { | |
| 59 | + | return true | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | return false | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // IsBlockedTarget checks if a target host is in a blocked network range. | |
| 66 | + | // It handles both IP addresses and hostnames (via DNS resolution). | |
| 67 | + | // Returns (blocked, error) where error indicates DNS resolution failure. | |
| 68 | + | // | |
| 69 | + | // SECURITY: This function is used to prevent SSRF attacks by blocking | |
| 70 | + | // requests to internal networks, localhost, and cloud metadata endpoints. | |
| 71 | + | func IsBlockedTarget(host string) (bool, error) { | |
| 72 | + | initBlockedCIDRs() | |
| 73 | + | ||
| 74 | + | // First, try to parse as IP directly | |
| 75 | + | if ip := net.ParseIP(host); ip != nil { | |
| 76 | + | return isBlockedIP(ip), nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // Not an IP address, resolve DNS | |
| 80 | + | ips, err := net.LookupIP(host) | |
| 81 | + | if err != nil { | |
| 82 | + | // DNS resolution failed - block by default for safety | |
| 83 | + | // This prevents DNS rebinding attacks where an attacker controls | |
| 84 | + | // a domain that initially resolves to a public IP, then later | |
| 85 | + | // resolves to an internal IP. | |
| 86 | + | return true, fmt.Errorf("DNS resolution failed for %s: %w", host, err) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | if len(ips) == 0 { | |
| 90 | + | // No IPs resolved - block for safety | |
| 91 | + | return true, fmt.Errorf("no IP addresses found for %s", host) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Check all resolved IPs - block if ANY resolve to a blocked network | |
| 95 | + | for _, ip := range ips { | |
| 96 | + | if isBlockedIP(ip) { | |
| 97 | + | return true, nil | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | return false, nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // ValidateExternalRouteTarget validates that an external route target | |
| 105 | + | // is safe to proxy to. Returns domain.ErrSSRFBlocked if the target | |
| 106 | + | // resolves to an internal/blocked network. | |
| 107 | + | func ValidateExternalRouteTarget(host string) error { | |
| 108 | + | blocked, err := IsBlockedTarget(host) | |
| 109 | + | if err != nil { | |
| 110 | + | // DNS error - treat as blocked for security | |
| 111 | + | return fmt.Errorf("%w: %v", domain.ErrSSRFBlocked, err) | |
| 112 | + | } | |
| 113 | + | if blocked { | |
| 114 | + | return domain.ErrSSRFBlocked | |
| 115 | + | } | |
| 116 | + | return nil | |
| 117 | + | } | |
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | // Package proxy implements the reverse proxy use case. | |||
| 2 | + | package proxy | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "fmt" | |||
| 6 | + | "net" | |||
| 7 | + | "sync" | |||
| 8 | + | ||||
| 9 | + | "gordon/internal/domain" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // blockedCIDRStrings contains CIDR blocks that should never be proxied to. | |||
| 13 | + | // This prevents SSRF attacks targeting internal services or cloud metadata endpoints. | |||
| 14 | + | var blockedCIDRStrings = []string{ | |||
| 15 | + | // IPv4 loopback | |||
| 16 | + | "127.0.0.0/8", | |||
| 17 | + | // IPv4 private networks (RFC 1918) | |||
| 18 | + | "10.0.0.0/8", | |||
| 19 | + | "172.16.0.0/12", | |||
| 20 | + | "192.168.0.0/16", | |||
| 21 | + | // IPv4 link-local (cloud metadata endpoints like AWS/GCP/Azure) | |||
| 22 | + | "169.254.0.0/16", | |||
| 23 | + | // IPv4 localhost alternative | |||
| 24 | + | "0.0.0.0/8", | |||
| 25 | + | // IPv6 loopback | |||
| 26 | + | "::1/128", | |||
| 27 | + | // IPv6 unique local addresses (RFC 4193) | |||
| 28 | + | "fc00::/7", | |||
| 29 | + | // IPv6 link-local | |||
| 30 | + | "fe80::/10", | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | var ( | |||
| 34 | + | blockedCIDRs []*net.IPNet | |||
| 35 | + | blockedCIDRsOnce sync.Once | |||
| 36 | + | ) | |||
| 37 | + | ||||
| 38 | + | // initBlockedCIDRs parses the blocked CIDR strings into net.IPNet. | |||
| 39 | + | // Called lazily on first use. | |||
| 40 | + | func initBlockedCIDRs() { | |||
| 41 | + | blockedCIDRsOnce.Do(func() { | |||
| 42 | + | blockedCIDRs = make([]*net.IPNet, 0, len(blockedCIDRStrings)) | |||
| 43 | + | for _, cidr := range blockedCIDRStrings { | |||
| 44 | + | _, network, err := net.ParseCIDR(cidr) | |||
| 45 | + | if err != nil { | |||
| 46 | + | // This should never happen with our hardcoded CIDRs | |||
| 47 | + | panic(fmt.Sprintf("invalid blocked CIDR: %s: %v", cidr, err)) | |||
| 48 | + | } | |||
| 49 | + | blockedCIDRs = append(blockedCIDRs, network) | |||
| 50 | + | } | |||
| 51 | + | }) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // isBlockedIP checks if an IP address is in any of the blocked CIDR ranges. | |||
| 55 | + | func isBlockedIP(ip net.IP) bool { | |||
| 56 | + | initBlockedCIDRs() | |||
| 57 | + | for _, network := range blockedCIDRs { | |||
| 58 | + | if network.Contains(ip) { | |||
| 59 | + | return true | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | return false | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // IsBlockedTarget checks if a target host is in a blocked network range. | |||
| 66 | + | // It handles both IP addresses and hostnames (via DNS resolution). | |||
| 67 | + | // Returns (blocked, error) where error indicates DNS resolution failure. | |||
| 68 | + | // | |||
| 69 | + | // SECURITY: This function is used to prevent SSRF attacks by blocking | |||
| 70 | + | // requests to internal networks, localhost, and cloud metadata endpoints. | |||
| 71 | + | func IsBlockedTarget(host string) (bool, error) { | |||
| 72 | + | initBlockedCIDRs() | |||
| 73 | + | ||||
| 74 | + | // First, try to parse as IP directly | |||
| 75 | + | if ip := net.ParseIP(host); ip != nil { | |||
| 76 | + | return isBlockedIP(ip), nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Not an IP address, resolve DNS | |||
| 80 | + | ips, err := net.LookupIP(host) | |||
| 81 | + | if err != nil { | |||
| 82 | + | // DNS resolution failed - block by default for safety | |||
| 83 | + | // This prevents DNS rebinding attacks where an attacker controls | |||
| 84 | + | // a domain that initially resolves to a public IP, then later | |||
| 85 | + | // resolves to an internal IP. | |||
| 86 | + | return true, fmt.Errorf("DNS resolution failed for %s: %w", host, err) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | if len(ips) == 0 { | |||
| 90 | + | // No IPs resolved - block for safety | |||
| 91 | + | return true, fmt.Errorf("no IP addresses found for %s", host) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // Check all resolved IPs - block if ANY resolve to a blocked network | |||
| 95 | + | for _, ip := range ips { | |||
| 96 | + | if isBlockedIP(ip) { | |||
| 97 | + | return true, nil | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | return false, nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // ValidateExternalRouteTarget validates that an external route target | |||
| 105 | + | // is safe to proxy to. Returns domain.ErrSSRFBlocked if the target | |||
| 106 | + | // resolves to an internal/blocked network. | |||
| 107 | + | func ValidateExternalRouteTarget(host string) error { | |||
| 108 | + | blocked, err := IsBlockedTarget(host) | |||
| 109 | + | if err != nil { | |||
| 110 | + | // DNS error - treat as blocked for security | |||
| 111 | + | return fmt.Errorf("%w: %v", domain.ErrSSRFBlocked, err) | |||
| 112 | + | } | |||
| 113 | + | if blocked { | |||
| 114 | + | return domain.ErrSSRFBlocked | |||
| 115 | + | } | |||
| 116 | + | return nil | |||
| 117 | + | } | |||
A internal/usecase/proxy/ssrf_test.go +191 -0
| @@ -0,0 +1,191 @@ | |||
| 1 | + | package proxy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "net" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "gordon/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestIsBlockedTarget_IPAddresses(t *testing.T) { | |
| 15 | + | tests := []struct { | |
| 16 | + | name string | |
| 17 | + | host string | |
| 18 | + | blocked bool | |
| 19 | + | }{ | |
| 20 | + | // IPv4 loopback - should block | |
| 21 | + | {"IPv4 loopback 127.0.0.1", "127.0.0.1", true}, | |
| 22 | + | {"IPv4 loopback 127.0.0.2", "127.0.0.2", true}, | |
| 23 | + | {"IPv4 loopback 127.255.255.255", "127.255.255.255", true}, | |
| 24 | + | ||
| 25 | + | // IPv4 private networks - should block | |
| 26 | + | {"10.0.0.0/8 network", "10.0.0.1", true}, | |
| 27 | + | {"10.0.0.0/8 network edge", "10.255.255.255", true}, | |
| 28 | + | {"172.16.0.0/12 network start", "172.16.0.1", true}, | |
| 29 | + | {"172.16.0.0/12 network middle", "172.20.0.1", true}, | |
| 30 | + | {"172.16.0.0/12 network end", "172.31.255.255", true}, | |
| 31 | + | {"192.168.0.0/16 network", "192.168.1.1", true}, | |
| 32 | + | {"192.168.0.0/16 network edge", "192.168.255.255", true}, | |
| 33 | + | ||
| 34 | + | // AWS/GCP/Azure metadata endpoint - should block | |
| 35 | + | {"AWS metadata endpoint", "169.254.169.254", true}, | |
| 36 | + | {"Link-local address", "169.254.0.1", true}, | |
| 37 | + | ||
| 38 | + | // IPv6 loopback - should block | |
| 39 | + | {"IPv6 loopback", "::1", true}, | |
| 40 | + | ||
| 41 | + | // IPv6 unique local - should block | |
| 42 | + | {"IPv6 unique local fc00", "fc00::1", true}, | |
| 43 | + | {"IPv6 unique local fd00", "fd00::1", true}, | |
| 44 | + | ||
| 45 | + | // IPv6 link-local - should block | |
| 46 | + | {"IPv6 link-local", "fe80::1", true}, | |
| 47 | + | ||
| 48 | + | // Public IPs - should allow | |
| 49 | + | {"Google DNS", "8.8.8.8", false}, | |
| 50 | + | {"Cloudflare DNS", "1.1.1.1", false}, | |
| 51 | + | {"Public IP", "203.0.113.1", false}, | |
| 52 | + | {"Public IPv6", "2001:4860:4860::8888", false}, | |
| 53 | + | ||
| 54 | + | // Edge cases for 172.16.0.0/12 | |
| 55 | + | {"Just outside 172.16.0.0/12", "172.15.255.255", false}, | |
| 56 | + | {"Just outside 172.16.0.0/12 upper", "172.32.0.0", false}, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | for _, tt := range tests { | |
| 60 | + | t.Run(tt.name, func(t *testing.T) { | |
| 61 | + | blocked, err := IsBlockedTarget(tt.host) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | assert.Equal(t, tt.blocked, blocked, "IsBlockedTarget(%q) = %v, want %v", tt.host, blocked, tt.blocked) | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestIsBlockedTarget_Hostnames(t *testing.T) { | |
| 69 | + | tests := []struct { | |
| 70 | + | name string | |
| 71 | + | host string | |
| 72 | + | wantBlocked bool | |
| 73 | + | wantErr bool | |
| 74 | + | }{ | |
| 75 | + | // Localhost hostname - should block | |
| 76 | + | { | |
| 77 | + | name: "localhost", | |
| 78 | + | host: "localhost", | |
| 79 | + | wantBlocked: true, | |
| 80 | + | wantErr: false, | |
| 81 | + | }, | |
| 82 | + | ||
| 83 | + | // Public domains - should allow (assuming they resolve to public IPs) | |
| 84 | + | { | |
| 85 | + | name: "google.com", | |
| 86 | + | host: "google.com", | |
| 87 | + | wantBlocked: false, | |
| 88 | + | wantErr: false, | |
| 89 | + | }, | |
| 90 | + | ||
| 91 | + | // Non-existent domain - should error and block | |
| 92 | + | { | |
| 93 | + | name: "non-existent domain", | |
| 94 | + | host: "this-domain-definitely-does-not-exist-12345.invalid", | |
| 95 | + | wantBlocked: true, | |
| 96 | + | wantErr: true, | |
| 97 | + | }, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | for _, tt := range tests { | |
| 101 | + | t.Run(tt.name, func(t *testing.T) { | |
| 102 | + | blocked, err := IsBlockedTarget(tt.host) | |
| 103 | + | ||
| 104 | + | if tt.wantErr { | |
| 105 | + | assert.Error(t, err) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | assert.Equal(t, tt.wantBlocked, blocked, "IsBlockedTarget(%q) blocked = %v, want %v", tt.host, blocked, tt.wantBlocked) | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestValidateExternalRouteTarget(t *testing.T) { | |
| 114 | + | tests := []struct { | |
| 115 | + | name string | |
| 116 | + | host string | |
| 117 | + | wantErr error | |
| 118 | + | }{ | |
| 119 | + | // Blocked targets | |
| 120 | + | { | |
| 121 | + | name: "loopback IP", | |
| 122 | + | host: "127.0.0.1", | |
| 123 | + | wantErr: domain.ErrSSRFBlocked, | |
| 124 | + | }, | |
| 125 | + | { | |
| 126 | + | name: "private network", | |
| 127 | + | host: "10.0.0.1", | |
| 128 | + | wantErr: domain.ErrSSRFBlocked, | |
| 129 | + | }, | |
| 130 | + | { | |
| 131 | + | name: "AWS metadata", | |
| 132 | + | host: "169.254.169.254", | |
| 133 | + | wantErr: domain.ErrSSRFBlocked, | |
| 134 | + | }, | |
| 135 | + | { | |
| 136 | + | name: "localhost", | |
| 137 | + | host: "localhost", | |
| 138 | + | wantErr: domain.ErrSSRFBlocked, | |
| 139 | + | }, | |
| 140 | + | ||
| 141 | + | // Allowed targets | |
| 142 | + | { | |
| 143 | + | name: "public IP", | |
| 144 | + | host: "8.8.8.8", | |
| 145 | + | wantErr: nil, | |
| 146 | + | }, | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | for _, tt := range tests { | |
| 150 | + | t.Run(tt.name, func(t *testing.T) { | |
| 151 | + | err := ValidateExternalRouteTarget(tt.host) | |
| 152 | + | ||
| 153 | + | if tt.wantErr != nil { | |
| 154 | + | require.Error(t, err) | |
| 155 | + | assert.True(t, errors.Is(err, tt.wantErr), "expected error %v, got %v", tt.wantErr, err) | |
| 156 | + | } else { | |
| 157 | + | assert.NoError(t, err) | |
| 158 | + | } | |
| 159 | + | }) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestIsBlockedIP(t *testing.T) { | |
| 164 | + | tests := []struct { | |
| 165 | + | ip string | |
| 166 | + | blocked bool | |
| 167 | + | }{ | |
| 168 | + | // Should block | |
| 169 | + | {"127.0.0.1", true}, | |
| 170 | + | {"10.0.0.1", true}, | |
| 171 | + | {"172.16.0.1", true}, | |
| 172 | + | {"192.168.1.1", true}, | |
| 173 | + | {"169.254.169.254", true}, | |
| 174 | + | {"0.0.0.0", true}, | |
| 175 | + | ||
| 176 | + | // Should allow | |
| 177 | + | {"8.8.8.8", false}, | |
| 178 | + | {"1.1.1.1", false}, | |
| 179 | + | {"203.0.113.1", false}, | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | for _, tt := range tests { | |
| 183 | + | t.Run(tt.ip, func(t *testing.T) { | |
| 184 | + | ip := net.ParseIP(tt.ip) | |
| 185 | + | require.NotNil(t, ip, "failed to parse IP: %s", tt.ip) | |
| 186 | + | ||
| 187 | + | blocked := isBlockedIP(ip) | |
| 188 | + | assert.Equal(t, tt.blocked, blocked, "isBlockedIP(%s) = %v, want %v", tt.ip, blocked, tt.blocked) | |
| 189 | + | }) | |
| 190 | + | } | |
| 191 | + | } | |
| @@ -0,0 +1,191 @@ | |||||
| 1 | + | package proxy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "net" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestIsBlockedTarget_IPAddresses(t *testing.T) { | |||
| 15 | + | tests := []struct { | |||
| 16 | + | name string | |||
| 17 | + | host string | |||
| 18 | + | blocked bool | |||
| 19 | + | }{ | |||
| 20 | + | // IPv4 loopback - should block | |||
| 21 | + | {"IPv4 loopback 127.0.0.1", "127.0.0.1", true}, | |||
| 22 | + | {"IPv4 loopback 127.0.0.2", "127.0.0.2", true}, | |||
| 23 | + | {"IPv4 loopback 127.255.255.255", "127.255.255.255", true}, | |||
| 24 | + | ||||
| 25 | + | // IPv4 private networks - should block | |||
| 26 | + | {"10.0.0.0/8 network", "10.0.0.1", true}, | |||
| 27 | + | {"10.0.0.0/8 network edge", "10.255.255.255", true}, | |||
| 28 | + | {"172.16.0.0/12 network start", "172.16.0.1", true}, | |||
| 29 | + | {"172.16.0.0/12 network middle", "172.20.0.1", true}, | |||
| 30 | + | {"172.16.0.0/12 network end", "172.31.255.255", true}, | |||
| 31 | + | {"192.168.0.0/16 network", "192.168.1.1", true}, | |||
| 32 | + | {"192.168.0.0/16 network edge", "192.168.255.255", true}, | |||
| 33 | + | ||||
| 34 | + | // AWS/GCP/Azure metadata endpoint - should block | |||
| 35 | + | {"AWS metadata endpoint", "169.254.169.254", true}, | |||
| 36 | + | {"Link-local address", "169.254.0.1", true}, | |||
| 37 | + | ||||
| 38 | + | // IPv6 loopback - should block | |||
| 39 | + | {"IPv6 loopback", "::1", true}, | |||
| 40 | + | ||||
| 41 | + | // IPv6 unique local - should block | |||
| 42 | + | {"IPv6 unique local fc00", "fc00::1", true}, | |||
| 43 | + | {"IPv6 unique local fd00", "fd00::1", true}, | |||
| 44 | + | ||||
| 45 | + | // IPv6 link-local - should block | |||
| 46 | + | {"IPv6 link-local", "fe80::1", true}, | |||
| 47 | + | ||||
| 48 | + | // Public IPs - should allow | |||
| 49 | + | {"Google DNS", "8.8.8.8", false}, | |||
| 50 | + | {"Cloudflare DNS", "1.1.1.1", false}, | |||
| 51 | + | {"Public IP", "203.0.113.1", false}, | |||
| 52 | + | {"Public IPv6", "2001:4860:4860::8888", false}, | |||
| 53 | + | ||||
| 54 | + | // Edge cases for 172.16.0.0/12 | |||
| 55 | + | {"Just outside 172.16.0.0/12", "172.15.255.255", false}, | |||
| 56 | + | {"Just outside 172.16.0.0/12 upper", "172.32.0.0", false}, | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | for _, tt := range tests { | |||
| 60 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 61 | + | blocked, err := IsBlockedTarget(tt.host) | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | assert.Equal(t, tt.blocked, blocked, "IsBlockedTarget(%q) = %v, want %v", tt.host, blocked, tt.blocked) | |||
| 64 | + | }) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestIsBlockedTarget_Hostnames(t *testing.T) { | |||
| 69 | + | tests := []struct { | |||
| 70 | + | name string | |||
| 71 | + | host string | |||
| 72 | + | wantBlocked bool | |||
| 73 | + | wantErr bool | |||
| 74 | + | }{ | |||
| 75 | + | // Localhost hostname - should block | |||
| 76 | + | { | |||
| 77 | + | name: "localhost", | |||
| 78 | + | host: "localhost", | |||
| 79 | + | wantBlocked: true, | |||
| 80 | + | wantErr: false, | |||
| 81 | + | }, | |||
| 82 | + | ||||
| 83 | + | // Public domains - should allow (assuming they resolve to public IPs) | |||
| 84 | + | { | |||
| 85 | + | name: "google.com", | |||
| 86 | + | host: "google.com", | |||
| 87 | + | wantBlocked: false, | |||
| 88 | + | wantErr: false, | |||
| 89 | + | }, | |||
| 90 | + | ||||
| 91 | + | // Non-existent domain - should error and block | |||
| 92 | + | { | |||
| 93 | + | name: "non-existent domain", | |||
| 94 | + | host: "this-domain-definitely-does-not-exist-12345.invalid", | |||
| 95 | + | wantBlocked: true, | |||
| 96 | + | wantErr: true, | |||
| 97 | + | }, | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | for _, tt := range tests { | |||
| 101 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 102 | + | blocked, err := IsBlockedTarget(tt.host) | |||
| 103 | + | ||||
| 104 | + | if tt.wantErr { | |||
| 105 | + | assert.Error(t, err) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | assert.Equal(t, tt.wantBlocked, blocked, "IsBlockedTarget(%q) blocked = %v, want %v", tt.host, blocked, tt.wantBlocked) | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestValidateExternalRouteTarget(t *testing.T) { | |||
| 114 | + | tests := []struct { | |||
| 115 | + | name string | |||
| 116 | + | host string | |||
| 117 | + | wantErr error | |||
| 118 | + | }{ | |||
| 119 | + | // Blocked targets | |||
| 120 | + | { | |||
| 121 | + | name: "loopback IP", | |||
| 122 | + | host: "127.0.0.1", | |||
| 123 | + | wantErr: domain.ErrSSRFBlocked, | |||
| 124 | + | }, | |||
| 125 | + | { | |||
| 126 | + | name: "private network", | |||
| 127 | + | host: "10.0.0.1", | |||
| 128 | + | wantErr: domain.ErrSSRFBlocked, | |||
| 129 | + | }, | |||
| 130 | + | { | |||
| 131 | + | name: "AWS metadata", | |||
| 132 | + | host: "169.254.169.254", | |||
| 133 | + | wantErr: domain.ErrSSRFBlocked, | |||
| 134 | + | }, | |||
| 135 | + | { | |||
| 136 | + | name: "localhost", | |||
| 137 | + | host: "localhost", | |||
| 138 | + | wantErr: domain.ErrSSRFBlocked, | |||
| 139 | + | }, | |||
| 140 | + | ||||
| 141 | + | // Allowed targets | |||
| 142 | + | { | |||
| 143 | + | name: "public IP", | |||
| 144 | + | host: "8.8.8.8", | |||
| 145 | + | wantErr: nil, | |||
| 146 | + | }, | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | for _, tt := range tests { | |||
| 150 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 151 | + | err := ValidateExternalRouteTarget(tt.host) | |||
| 152 | + | ||||
| 153 | + | if tt.wantErr != nil { | |||
| 154 | + | require.Error(t, err) | |||
| 155 | + | assert.True(t, errors.Is(err, tt.wantErr), "expected error %v, got %v", tt.wantErr, err) | |||
| 156 | + | } else { | |||
| 157 | + | assert.NoError(t, err) | |||
| 158 | + | } | |||
| 159 | + | }) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestIsBlockedIP(t *testing.T) { | |||
| 164 | + | tests := []struct { | |||
| 165 | + | ip string | |||
| 166 | + | blocked bool | |||
| 167 | + | }{ | |||
| 168 | + | // Should block | |||
| 169 | + | {"127.0.0.1", true}, | |||
| 170 | + | {"10.0.0.1", true}, | |||
| 171 | + | {"172.16.0.1", true}, | |||
| 172 | + | {"192.168.1.1", true}, | |||
| 173 | + | {"169.254.169.254", true}, | |||
| 174 | + | {"0.0.0.0", true}, | |||
| 175 | + | ||||
| 176 | + | // Should allow | |||
| 177 | + | {"8.8.8.8", false}, | |||
| 178 | + | {"1.1.1.1", false}, | |||
| 179 | + | {"203.0.113.1", false}, | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | for _, tt := range tests { | |||
| 183 | + | t.Run(tt.ip, func(t *testing.T) { | |||
| 184 | + | ip := net.ParseIP(tt.ip) | |||
| 185 | + | require.NotNil(t, ip, "failed to parse IP: %s", tt.ip) | |||
| 186 | + | ||||
| 187 | + | blocked := isBlockedIP(ip) | |||
| 188 | + | assert.Equal(t, tt.blocked, blocked, "isBlockedIP(%s) = %v, want %v", tt.ip, blocked, tt.blocked) | |||
| 189 | + | }) | |||
| 190 | + | } | |||
| 191 | + | } | |||