detached 55bbfad

fix(security): add SSRF protection for external routes

commit
55bbfad1bc571c4b6bf699de61f82887d48ffd6f
author
bnema · · 2026-01-17 20:56 UTC
parent
b77f360
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +371 -7
M internal/domain/errors.go +1 -0
@@ -62,6 +62,7 @@ var (
6262 ErrInvalidPath = errors.New("invalid path format")
6363 ErrInsufficientScope = errors.New("insufficient scope for operation")
6464 ErrInvalidScope = errors.New("invalid scope format")
65+ ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")
6566
6667 // Attachment errors
6768 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 errors67 // 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{
3939 type Config struct {
4040 RegistryDomain string
4141 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
4245 }
4346
4447 // Service implements the ProxyService interface.
@@ -140,6 +143,16 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
140143 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
141144 }
142145
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+
143156 target := &domain.ProxyTarget{
144157 Host: host,
145158 Port: port,
@@ -312,7 +325,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
312325 },
313326 func(resp *http.Response) error {
314327 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+ }
316333 return nil
317334 },
318335 )
@@ -39,6 +39,9 @@ var proxyTransport = &http.Transport{
39 type Config struct {39 type Config struct {
40 RegistryDomain string40 RegistryDomain string
41 RegistryPort int41 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 nil333 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) {
7373 svc := NewService(runtime, containerSvc, configSvc, Config{})
7474 ctx := testContext()
7575
76- // Mock external routes
76+ // Mock external routes - use public IP to pass SSRF check
7777 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
7979 })
8080
8181 result, err := svc.GetTarget(ctx, "reg.example.com")
8282
8383 assert.NoError(t, err)
84- assert.Equal(t, "localhost", result.Host)
84+ assert.Equal(t, "203.0.113.10", result.Host)
8585 assert.Equal(t, 5000, result.Port)
8686 assert.Equal(t, "", result.ContainerID)
8787 assert.Equal(t, "http", result.Scheme)
@@ -95,14 +95,14 @@ func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
9595 svc := NewService(runtime, containerSvc, configSvc, Config{})
9696 ctx := testContext()
9797
98- // First call - should resolve external route
98+ // First call - should resolve external route (use public IP)
9999 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
100- "reg.example.com": "localhost:5000",
100+ "reg.example.com": "203.0.113.10:5000",
101101 }).Once()
102102
103103 result1, err := svc.GetTarget(ctx, "reg.example.com")
104104 assert.NoError(t, err)
105- assert.Equal(t, "localhost", result1.Host)
105+ assert.Equal(t, "203.0.113.10", result1.Host)
106106
107107 // Second call - should return from cache (no mock call needed)
108108 result2, err := svc.GetTarget(ctx, "reg.example.com")
@@ -110,6 +110,44 @@ func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
110110 assert.Equal(t, result1, result2)
111111 }
112112
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+
113151 func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
114152 runtime := outmocks.NewMockContainerRuntime(t)
115153 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 routes76+ // 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 route98+ // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close