e80b18370197c358e6792fab66c816ad27f408ce
- Author
- Vinayak Mishra <viks@vnykmshr.com>
- Committer
- GitHub <noreply@github.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/pkg/lfs/http_client.go b/pkg/lfs/http_client.go
2index 068c311355f63ba713b8e98335ecb81a8dffc303..22bfa7863176620f0de6885ae64647a7b3542e05 100644
3--- a/pkg/lfs/http_client.go
4+++ b/pkg/lfs/http_client.go
5@@ -9,6 +9,7 @@ import (
6 "net/http"
7
8 "charm.land/log/v2"
9+ "github.com/charmbracelet/soft-serve/pkg/ssrf"
10 )
11
12 // httpClient is a Git LFS client to communicate with a LFS source API.
13@@ -22,11 +23,12 @@ var _ Client = (*httpClient)(nil)
14
15 // newHTTPClient returns a new Git LFS client.
16 func newHTTPClient(endpoint Endpoint) *httpClient {
17+ client := ssrf.NewSecureClient()
18 return &httpClient{
19- client: http.DefaultClient,
20+ client: client,
21 endpoint: endpoint,
22 transfers: map[string]TransferAdapter{
23- TransferBasic: &BasicTransferAdapter{http.DefaultClient},
24+ TransferBasic: &BasicTransferAdapter{client},
25 },
26 }
27 }
28diff --git a/pkg/ssrf/ssrf.go b/pkg/ssrf/ssrf.go
29new file mode 100644
30index 0000000000000000000000000000000000000000..1ed96bd8f88c7005276e2d772161f3dbddf7c4a1
31--- /dev/null
32+++ b/pkg/ssrf/ssrf.go
33@@ -0,0 +1,178 @@
34+package ssrf
35+
36+import (
37+ "context"
38+ "errors"
39+ "fmt"
40+ "net"
41+ "net/http"
42+ "net/url"
43+ "slices"
44+ "strings"
45+ "time"
46+)
47+
48+var (
49+ // ErrPrivateIP is returned when a connection to a private or internal IP is blocked.
50+ ErrPrivateIP = errors.New("connection to private or internal IP address is not allowed")
51+ // ErrInvalidScheme is returned when a URL scheme is not http or https.
52+ ErrInvalidScheme = errors.New("URL must use http or https scheme")
53+ // ErrInvalidURL is returned when a URL is invalid.
54+ ErrInvalidURL = errors.New("invalid URL")
55+)
56+
57+// NewSecureClient returns an HTTP client with SSRF protection.
58+// It validates resolved IPs at dial time to block connections to private
59+// and internal networks. Since validation uses the already-resolved IP
60+// from the Transport's DNS lookup, there is no TOCTOU gap between
61+// resolution and connection. Redirects are disabled to match the
62+// webhook client convention and prevent redirect-based SSRF.
63+func NewSecureClient() *http.Client {
64+ return &http.Client{
65+ Timeout: 30 * time.Second,
66+ Transport: &http.Transport{
67+ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
68+ host, _, err := net.SplitHostPort(addr)
69+ if err != nil {
70+ return nil, err //nolint:wrapcheck
71+ }
72+
73+ ip := net.ParseIP(host)
74+ if ip == nil {
75+ return nil, fmt.Errorf("unexpected non-IP address in dial: %s", host)
76+ }
77+ if isPrivateOrInternal(ip) {
78+ return nil, fmt.Errorf("%w", ErrPrivateIP)
79+ }
80+
81+ dialer := &net.Dialer{
82+ Timeout: 10 * time.Second,
83+ KeepAlive: 30 * time.Second,
84+ }
85+ return dialer.DialContext(ctx, network, addr)
86+ },
87+ MaxIdleConns: 100,
88+ IdleConnTimeout: 90 * time.Second,
89+ TLSHandshakeTimeout: 10 * time.Second,
90+ ExpectContinueTimeout: 1 * time.Second,
91+ },
92+ CheckRedirect: func(*http.Request, []*http.Request) error {
93+ return http.ErrUseLastResponse
94+ },
95+ }
96+}
97+
98+// isPrivateOrInternal checks if an IP address is private, internal, or reserved.
99+func isPrivateOrInternal(ip net.IP) bool {
100+ // Normalize IPv6-mapped IPv4 (e.g. ::ffff:127.0.0.1) to IPv4 form
101+ // so all checks apply consistently.
102+ if ip4 := ip.To4(); ip4 != nil {
103+ ip = ip4
104+ }
105+
106+ if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
107+ ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() {
108+ return true
109+ }
110+
111+ if ip4 := ip.To4(); ip4 != nil {
112+ // 0.0.0.0/8
113+ if ip4[0] == 0 {
114+ return true
115+ }
116+ // 100.64.0.0/10 (Shared Address Space / CGNAT)
117+ if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
118+ return true
119+ }
120+ // 192.0.0.0/24 (IETF Protocol Assignments)
121+ if ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 0 {
122+ return true
123+ }
124+ // 192.0.2.0/24 (TEST-NET-1)
125+ if ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 2 {
126+ return true
127+ }
128+ // 198.18.0.0/15 (benchmarking)
129+ if ip4[0] == 198 && (ip4[1] == 18 || ip4[1] == 19) {
130+ return true
131+ }
132+ // 198.51.100.0/24 (TEST-NET-2)
133diff --git a/pkg/ssrf/ssrf_test.go b/pkg/ssrf/ssrf_test.go
134new file mode 100644
135index 0000000000000000000000000000000000000000..a3c684dcf1babf37855c20a734aceeda6ceb1107
136--- /dev/null
137+++ b/pkg/ssrf/ssrf_test.go
138@@ -0,0 +1,208 @@
139+package ssrf
140+
141+import (
142+ "context"
143+ "errors"
144+ "net"
145+ "net/http"
146+ "net/http/httptest"
147+ "testing"
148+ "time"
149+)
150+
151+func TestNewSecureClientBlocksPrivateIPs(t *testing.T) {
152+ client := NewSecureClient()
153+ transport := client.Transport.(*http.Transport)
154+
155+ tests := []struct {
156+ name string
157+ addr string
158+ wantErr bool
159+ }{
160+ {"block loopback", "127.0.0.1:80", true},
161+ {"block private 10.x", "10.0.0.1:80", true},
162+ {"block link-local", "169.254.169.254:80", true},
163+ {"block CGNAT", "100.64.0.1:80", true},
164+ {"allow public IP", "8.8.8.8:80", false},
165+ }
166+
167+ for _, tt := range tests {
168+ t.Run(tt.name, func(t *testing.T) {
169+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
170+ defer cancel()
171+
172+ conn, err := transport.DialContext(ctx, "tcp", tt.addr)
173+ if conn != nil {
174+ conn.Close()
175+ }
176+
177+ if tt.wantErr {
178+ if err == nil {
179+ t.Errorf("expected error for %s, got none", tt.addr)
180+ }
181+ } else {
182+ if err != nil && errors.Is(err, ErrPrivateIP) {
183+ t.Errorf("should not block %s with SSRF error, got: %v", tt.addr, err)
184+ }
185+ }
186+ })
187+ }
188+}
189+
190+func TestNewSecureClientNilIPNotErrPrivateIP(t *testing.T) {
191+ client := NewSecureClient()
192+ transport := client.Transport.(*http.Transport)
193+
194+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
195+ defer cancel()
196+
197+ conn, err := transport.DialContext(ctx, "tcp", "not-an-ip:80")
198+ if conn != nil {
199+ conn.Close()
200+ }
201+ if err == nil {
202+ t.Fatal("expected error for non-IP address, got none")
203+ }
204+ if errors.Is(err, ErrPrivateIP) {
205+ t.Errorf("nil-IP path should not wrap ErrPrivateIP, got: %v", err)
206+ }
207+}
208+
209+func TestNewSecureClientBlocksRedirects(t *testing.T) {
210+ redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
211+ http.Redirect(w, r, "http://8.8.8.8:8080/safe", http.StatusFound)
212+ }))
213+ defer redirectServer.Close()
214+
215+ client := NewSecureClient()
216+ req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, redirectServer.URL, nil)
217+ if err != nil {
218+ t.Fatalf("Failed to create request: %v", err)
219+ }
220+
221+ resp, err := client.Do(req)
222+ if err != nil {
223+ // httptest uses 127.0.0.1, blocked by SSRF protection
224+ if !errors.Is(err, ErrPrivateIP) {
225+ t.Fatalf("Request failed with non-SSRF error: %v", err)
226+ }
227+ return
228+ }
229+ defer resp.Body.Close()
230+
231+ if resp.StatusCode != http.StatusFound {
232+ t.Errorf("Expected redirect response (302), got %d", resp.StatusCode)
233+ }
234+}
235+
236+func TestIsPrivateOrInternal(t *testing.T) {
237+ tests := []struct {
238diff --git a/pkg/webhook/ssrf_test.go b/pkg/webhook/ssrf_test.go
239index 3f7fc4948aad217d65a6e705d02fffeeda465f4f..24251613638115b1d2c513a00b6ea6f0a650b465 100644
240--- a/pkg/webhook/ssrf_test.go
241+++ b/pkg/webhook/ssrf_test.go
242@@ -2,217 +2,57 @@ package webhook
243
244 import (
245 "context"
246+ "errors"
247 "net/http"
248- "net/http/httptest"
249 "testing"
250 "time"
251
252 "github.com/charmbracelet/soft-serve/pkg/db/models"
253+ "github.com/charmbracelet/soft-serve/pkg/ssrf"
254 )
255
256-// TestSSRFProtection tests that the webhook system blocks SSRF attempts.
257+// TestSSRFProtection is an integration test verifying the webhook send path
258+// blocks private IPs end-to-end (models.Webhook -> secureHTTPClient -> ssrf).
259 func TestSSRFProtection(t *testing.T) {
260 tests := []struct {
261 name string
262 webhookURL string
263 shouldBlock bool
264- description string
265 }{
266- {
267- name: "block localhost",
268- webhookURL: "http://localhost:8080/webhook",
269- shouldBlock: true,
270- description: "should block localhost addresses",
271- },
272- {
273- name: "block 127.0.0.1",
274- webhookURL: "http://127.0.0.1:8080/webhook",
275- shouldBlock: true,
276- description: "should block loopback addresses",
277- },
278- {
279- name: "block 169.254.169.254",
280- webhookURL: "http://169.254.169.254/latest/meta-data/",
281- shouldBlock: true,
282- description: "should block cloud metadata service",
283- },
284- {
285- name: "block private network",
286- webhookURL: "http://192.168.1.1/webhook",
287- shouldBlock: true,
288- description: "should block private networks",
289- },
290- {
291- name: "allow public IP",
292- webhookURL: "http://8.8.8.8/webhook",
293- shouldBlock: false,
294- description: "should allow public IP addresses",
295- },
296+ {"block loopback", "http://127.0.0.1:8080/webhook", true},
297+ {"block metadata", "http://169.254.169.254/latest/meta-data/", true},
298+ {"allow public IP", "http://8.8.8.8/webhook", false},
299 }
300
301 for _, tt := range tests {
302 t.Run(tt.name, func(t *testing.T) {
303- // Create a test webhook
304- webhook := models.Webhook{
305+ w := models.Webhook{
306 URL: tt.webhookURL,
307 ContentType: int(ContentTypeJSON),
308- Secret: "",
309 }
310
311- // Try to send a webhook
312 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
313 defer cancel()
314
315- // Create a simple payload
316- payload := map[string]string{"test": "data"}
317-
318- err := sendWebhookWithContext(ctx, webhook, EventPush, payload)
319-
320- if tt.shouldBlock {
321- if err == nil {
322- t.Errorf("%s: expected error but got none", tt.description)
323- }
324- } else {
325- // For public IPs, we expect a connection error (since 8.8.8.8 won't be listening)
326- // but NOT an SSRF blocking error
327- if err != nil && isSSRFError(err) {
328- t.Errorf("%s: should not block public IPs, got: %v", tt.description, err)
329- }
330+ req, err := http.NewRequestWithContext(ctx, "POST", w.URL, nil)
331+ if err != nil {
332+ t.Fatalf("failed to create request: %v", err)
333 }
334- })
335- }
336-}
337-
338-// TestSecureHTTPClientBlocksRedirects tests that redirects are not followed.
339-func TestSecureHTTPClientBlocksRedirects(t *testing.T) {
340- // Create a test server on a public-looking address that redirects
341- redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
342diff --git a/pkg/webhook/validator.go b/pkg/webhook/validator.go
343index 0eecd0951173b7cb518b57a0c0826383f18e3e82..2cc13a7f15445fc56c4e576f897ceb381e3f8eba 100644
344--- a/pkg/webhook/validator.go
345+++ b/pkg/webhook/validator.go
346@@ -1,169 +1,20 @@
347 package webhook
348
349 import (
350- "context"
351- "errors"
352- "fmt"
353- "net"
354- "net/url"
355- "slices"
356- "strings"
357+ "github.com/charmbracelet/soft-serve/pkg/ssrf"
358 )
359
360+// Error aliases for backward compatibility.
361 var (
362- // ErrInvalidScheme is returned when the webhook URL scheme is not http or https.
363- ErrInvalidScheme = errors.New("webhook URL must use http or https scheme")
364- // ErrPrivateIP is returned when the webhook URL resolves to a private IP address.
365- ErrPrivateIP = errors.New("webhook URL cannot resolve to private or internal IP addresses")
366- // ErrInvalidURL is returned when the webhook URL is invalid.
367- ErrInvalidURL = errors.New("invalid webhook URL")
368+ ErrInvalidScheme = ssrf.ErrInvalidScheme
369+ ErrPrivateIP = ssrf.ErrPrivateIP
370+ ErrInvalidURL = ssrf.ErrInvalidURL
371 )
372
373 // ValidateWebhookURL validates that a webhook URL is safe to use.
374-// It checks:
375-// - URL is properly formatted
376-// - Scheme is http or https
377-// - Hostname does not resolve to private/internal IP addresses
378-// - Hostname is not localhost or similar.
379 func ValidateWebhookURL(rawURL string) error {
380- if rawURL == "" {
381- return ErrInvalidURL
382- }
383-
384- // Parse the URL
385- u, err := url.Parse(rawURL)
386- if err != nil {
387- return fmt.Errorf("%w: %v", ErrInvalidURL, err)
388- }
389-
390- // Check scheme
391- if u.Scheme != "http" && u.Scheme != "https" {
392- return ErrInvalidScheme
393- }
394-
395- // Extract hostname (without port)
396- hostname := u.Hostname()
397- if hostname == "" {
398- return fmt.Errorf("%w: missing hostname", ErrInvalidURL)
399- }
400-
401- // Check for localhost variations
402- if isLocalhost(hostname) {
403- return ErrPrivateIP
404- }
405-
406- // If it's an IP address, validate it directly
407- if ip := net.ParseIP(hostname); ip != nil {
408- if isPrivateOrInternalIP(ip) {
409- return ErrPrivateIP
410- }
411- return nil
412- }
413-
414- // Resolve hostname to IP addresses
415- ips, err := net.DefaultResolver.LookupIPAddr(context.Background(), hostname)
416- if err != nil {
417- return fmt.Errorf("%w: cannot resolve hostname: %v", ErrInvalidURL, err)
418- }
419-
420- // Check all resolved IPs
421- if slices.ContainsFunc(ips, isPrivateOrInternalIPAddr) {
422- return ErrPrivateIP
423- }
424-
425- return nil
426-}
427-
428-// isLocalhost checks if the hostname is localhost or similar.
429-func isLocalhost(hostname string) bool {
430- hostname = strings.ToLower(hostname)
431- return hostname == "localhost" ||
432- hostname == "localhost.localdomain" ||
433- strings.HasSuffix(hostname, ".localhost")
434-}
435-
436-// isPrivateOrInternalIPAddr is a helper function that users net.IPAddr instead of net.IP.
437-func isPrivateOrInternalIPAddr(ipAddr net.IPAddr) bool {
438- return isPrivateOrInternalIP(ipAddr.IP)
439-}
440-
441-// isPrivateOrInternalIP checks if an IP address is private, internal, or reserved.
442-func isPrivateOrInternalIP(ip net.IP) bool {
443- // Loopback addresses (127.0.0.0/8, ::1)
444- if ip.IsLoopback() {
445- return true
446diff --git a/pkg/webhook/validator_test.go b/pkg/webhook/validator_test.go
447index 9d4d4f2ac542dbe3d433e16e18c90d78e6236708..901d44a389bd08c2a36378976c139aa4855faca4 100644
448--- a/pkg/webhook/validator_test.go
449+++ b/pkg/webhook/validator_test.go
450@@ -1,315 +1,52 @@
451 package webhook
452
453 import (
454- "net"
455+ "errors"
456 "testing"
457+
458+ "github.com/charmbracelet/soft-serve/pkg/ssrf"
459 )
460
461+// TestValidateWebhookURL verifies the wrapper delegates correctly and
462+// error aliases work across the package boundary. IP range coverage
463+// is in pkg/ssrf/ssrf_test.go -- here we just confirm the plumbing.
464 func TestValidateWebhookURL(t *testing.T) {
465 tests := []struct {
466 name string
467 url string
468 wantErr bool
469 errType error
470- skip string
471 }{
472- // Valid URLs (these will perform DNS lookups, so may fail in some environments)
473- {
474- name: "valid https URL",
475- url: "https://1.1.1.1/webhook",
476- wantErr: false,
477- },
478- {
479- name: "valid http URL",
480- url: "http://8.8.8.8/webhook",
481- wantErr: false,
482- },
483- {
484- name: "valid URL with port",
485- url: "https://1.1.1.1:8080/webhook",
486- wantErr: false,
487- },
488- {
489- name: "valid URL with path and query",
490- url: "https://8.8.8.8/webhook?token=abc123",
491- wantErr: false,
492- },
493-
494- // Invalid schemes
495- {
496- name: "ftp scheme",
497- url: "ftp://example.com/webhook",
498- wantErr: true,
499- errType: ErrInvalidScheme,
500- },
501- {
502- name: "file scheme",
503- url: "file:///etc/passwd",
504- wantErr: true,
505- errType: ErrInvalidScheme,
506- },
507- {
508- name: "gopher scheme",
509- url: "gopher://example.com",
510- wantErr: true,
511- errType: ErrInvalidScheme,
512- },
513- {
514- name: "no scheme",
515- url: "example.com/webhook",
516- wantErr: true,
517- errType: ErrInvalidScheme,
518- },
519-
520- // Localhost variations
521- {
522- name: "localhost",
523- url: "http://localhost/webhook",
524- wantErr: true,
525- errType: ErrPrivateIP,
526- },
527- {
528- name: "localhost with port",
529- url: "http://localhost:8080/webhook",
530- wantErr: true,
531- errType: ErrPrivateIP,
532- },
533- {
534- name: "localhost.localdomain",
535- url: "http://localhost.localdomain/webhook",
536- wantErr: true,
537- errType: ErrPrivateIP,
538- },
539-
540- // Loopback IPs
541- {
542- name: "127.0.0.1",
543- url: "http://127.0.0.1/webhook",
544- wantErr: true,
545- errType: ErrPrivateIP,
546- },
547- {
548- name: "127.0.0.1 with port",
549- url: "http://127.0.0.1:8080/webhook",
550diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go
551index 176d8e25b56f7146ba39a9912493f0d94ca83d9c..dc3fe7dcb65d493c69932bf01e0fbce648b32a07 100644
552--- a/pkg/webhook/webhook.go
553+++ b/pkg/webhook/webhook.go
554@@ -10,14 +10,13 @@ import (
555 "errors"
556 "fmt"
557 "io"
558- "net"
559 "net/http"
560- "time"
561
562 "github.com/charmbracelet/soft-serve/git"
563 "github.com/charmbracelet/soft-serve/pkg/db"
564 "github.com/charmbracelet/soft-serve/pkg/db/models"
565 "github.com/charmbracelet/soft-serve/pkg/proto"
566+ "github.com/charmbracelet/soft-serve/pkg/ssrf"
567 "github.com/charmbracelet/soft-serve/pkg/store"
568 "github.com/charmbracelet/soft-serve/pkg/utils"
569 "github.com/charmbracelet/soft-serve/pkg/version"
570@@ -38,42 +37,8 @@ type Delivery struct {
571 Event Event
572 }
573
574-// secureHTTPClient creates an HTTP client with SSRF protection.
575-var secureHTTPClient = &http.Client{
576- Timeout: 30 * time.Second,
577- Transport: &http.Transport{
578- DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
579- // Parse the address to get the IP
580- host, _, err := net.SplitHostPort(addr)
581- if err != nil {
582- return nil, err //nolint:wrapcheck
583- }
584-
585- // Validate the resolved IP before connecting
586- ip := net.ParseIP(host)
587- if ip != nil {
588- if err := ValidateIPBeforeDial(ip); err != nil {
589- return nil, fmt.Errorf("blocked connection to private IP: %w", err)
590- }
591- }
592-
593- // Use standard dialer with timeout
594- dialer := &net.Dialer{
595- Timeout: 10 * time.Second,
596- KeepAlive: 30 * time.Second,
597- }
598- return dialer.DialContext(ctx, network, addr)
599- },
600- MaxIdleConns: 100,
601- IdleConnTimeout: 90 * time.Second,
602- TLSHandshakeTimeout: 10 * time.Second,
603- ExpectContinueTimeout: 1 * time.Second,
604- },
605- // Don't follow redirects to prevent bypassing IP validation
606- CheckRedirect: func(*http.Request, []*http.Request) error {
607- return http.ErrUseLastResponse
608- },
609-}
610+// secureHTTPClient is an HTTP client with SSRF protection.
611+var secureHTTPClient = ssrf.NewSecureClient()
612
613 // do sends a webhook.
614 // Caller must close the returned body.