ssrf_test.go

  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}