1package ssrf
2
3import (
4 "context"
5 "errors"
6 "net"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10 "time"
11)
12
13func TestNewSecureClientBlocksPrivateIPs(t *testing.T) {
14 client := NewSecureClient()
15 transport := client.Transport.(*http.Transport)
16
17 tests := []struct {
18 name string
19 addr string
20 wantErr bool
21 }{
22 {"block loopback", "127.0.0.1:80", true},
23 {"block private 10.x", "10.0.0.1:80", true},
24 {"block link-local", "169.254.169.254:80", true},
25 {"block CGNAT", "100.64.0.1:80", true},
26 {"allow public IP", "8.8.8.8:80", false},
27 }
28
29 for _, tt := range tests {
30 t.Run(tt.name, func(t *testing.T) {
31 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
32 defer cancel()
33
34 conn, err := transport.DialContext(ctx, "tcp", tt.addr)
35 if conn != nil {
36 conn.Close()
37 }
38
39 if tt.wantErr {
40 if err == nil {
41 t.Errorf("expected error for %s, got none", tt.addr)
42 }
43 } else {
44 if err != nil && errors.Is(err, ErrPrivateIP) {
45 t.Errorf("should not block %s with SSRF error, got: %v", tt.addr, err)
46 }
47 }
48 })
49 }
50}
51
52func TestNewSecureClientNilIPNotErrPrivateIP(t *testing.T) {
53 client := NewSecureClient()
54 transport := client.Transport.(*http.Transport)
55
56 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
57 defer cancel()
58
59 conn, err := transport.DialContext(ctx, "tcp", "not-an-ip:80")
60 if conn != nil {
61 conn.Close()
62 }
63 if err == nil {
64 t.Fatal("expected error for non-IP address, got none")
65 }
66 if errors.Is(err, ErrPrivateIP) {
67 t.Errorf("nil-IP path should not wrap ErrPrivateIP, got: %v", err)
68 }
69}
70
71func TestNewSecureClientBlocksRedirects(t *testing.T) {
72 redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
73 http.Redirect(w, r, "http://8.8.8.8:8080/safe", http.StatusFound)
74 }))
75 defer redirectServer.Close()
76
77 client := NewSecureClient()
78 req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, redirectServer.URL, nil)
79 if err != nil {
80 t.Fatalf("Failed to create request: %v", err)
81 }
82
83 resp, err := client.Do(req)
84 if err != nil {
85 // httptest uses 127.0.0.1, blocked by SSRF protection
86 if !errors.Is(err, ErrPrivateIP) {
87 t.Fatalf("Request failed with non-SSRF error: %v", err)
88 }
89 return
90 }
91 defer resp.Body.Close()
92
93 if resp.StatusCode != http.StatusFound {
94 t.Errorf("Expected redirect response (302), got %d", resp.StatusCode)
95 }
96}
97
98func TestIsPrivateOrInternal(t *testing.T) {
99 tests := []struct {
100 ip string
101 want bool
102 }{
103 // Public
104 {"8.8.8.8", false},
105 {"2001:4860:4860::8888", false},
106
107 // Loopback
108 {"127.0.0.1", true},
109 {"::1", true},
110
111 // Private ranges
112 {"10.0.0.1", true},
113 {"192.168.1.1", true},
114 {"172.16.0.1", true},
115
116 // Link-local (cloud metadata)
117 {"169.254.169.254", true},
118
119 // CGNAT boundaries
120 {"100.64.0.1", true},
121 {"100.127.255.255", true},
122
123 // IPv6-mapped IPv4 (bypass vector the old webhook code missed)
124 {"::ffff:127.0.0.1", true},
125 {"::ffff:169.254.169.254", true},
126 {"::ffff:8.8.8.8", false},
127
128 // Reserved
129 {"0.0.0.0", true},
130 {"240.0.0.1", true},
131 }
132
133 for _, tt := range tests {
134 t.Run(tt.ip, func(t *testing.T) {
135 ip := net.ParseIP(tt.ip)
136 if ip == nil {
137 t.Fatalf("failed to parse IP: %s", tt.ip)
138 }
139 if got := isPrivateOrInternal(ip); got != tt.want {
140 t.Errorf("isPrivateOrInternal(%s) = %v, want %v", tt.ip, got, tt.want)
141 }
142 })
143 }
144}
145
146func TestValidateURL(t *testing.T) {
147 tests := []struct {
148 name string
149 url string
150 wantErr bool
151 errType error
152 }{
153 // Valid
154 {"valid https", "https://1.1.1.1/webhook", false, nil},
155
156 // Scheme validation
157 {"ftp scheme", "ftp://example.com/webhook", true, ErrInvalidScheme},
158 {"no scheme", "example.com/webhook", true, ErrInvalidScheme},
159
160 // Localhost
161 {"localhost", "http://localhost/webhook", true, ErrPrivateIP},
162 {"subdomain.localhost", "http://test.localhost/webhook", true, ErrPrivateIP},
163
164 // IP-based blocking (one per category -- range coverage is in TestIsPrivateOrInternal)
165 {"loopback IP", "http://127.0.0.1/webhook", true, ErrPrivateIP},
166 {"metadata IP", "http://169.254.169.254/latest/meta-data/", true, ErrPrivateIP},
167
168 // Invalid URLs
169 {"empty", "", true, ErrInvalidURL},
170 {"missing hostname", "http:///webhook", true, ErrInvalidURL},
171 }
172
173 for _, tt := range tests {
174 t.Run(tt.name, func(t *testing.T) {
175 err := ValidateURL(tt.url)
176 if (err != nil) != tt.wantErr {
177 t.Errorf("ValidateURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
178 return
179 }
180 if tt.wantErr && tt.errType != nil {
181 if !errors.Is(err, tt.errType) {
182 t.Errorf("ValidateURL(%q) error = %v, want error type %v", tt.url, err, tt.errType)
183 }
184 }
185 })
186 }
187}
188
189func TestIsLocalhost(t *testing.T) {
190 tests := []struct {
191 hostname string
192 want bool
193 }{
194 {"localhost", true},
195 {"LOCALHOST", true},
196 {"test.localhost", true},
197 {"example.com", false},
198 {"localhost.com", false},
199 }
200
201 for _, tt := range tests {
202 t.Run(tt.hostname, func(t *testing.T) {
203 if got := isLocalhost(tt.hostname); got != tt.want {
204 t.Errorf("isLocalhost(%s) = %v, want %v", tt.hostname, got, tt.want)
205 }
206 })
207 }
208}