bb73b9a0eea0d902da4811420535842a4f9aae3b
- Author
- Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
- Committer
- GitHub <noreply@github.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/go.mod b/go.mod
2index 32d153560a1b755d95b46daa41f7f473538c84bc..4f8235f45eba21a0fd0d1b93bf4eeaf70a481a97 100644
3--- a/go.mod
4+++ b/go.mod
5@@ -1,6 +1,6 @@
6 module github.com/charmbracelet/soft-serve
7
8-go 1.23.0
9+go 1.24.0
10
11 require (
12 github.com/alecthomas/chroma/v2 v2.20.0
13diff --git a/pkg/backend/webhooks.go b/pkg/backend/webhooks.go
14index f217c30337f374f7ff0b1aace8c19a86bfb19073..d25d1cf6ec9ddd307d96ba3683ea25ff62882ff6 100644
15--- a/pkg/backend/webhooks.go
16+++ b/pkg/backend/webhooks.go
17@@ -20,6 +20,11 @@ func (b *Backend) CreateWebhook(ctx context.Context, repo proto.Repository, url
18 datastore := store.FromContext(ctx)
19 url = utils.Sanitize(url)
20
21+ // Validate webhook URL to prevent SSRF attacks
22+ if err := webhook.ValidateWebhookURL(url); err != nil {
23+ return err //nolint:wrapcheck
24+ }
25+
26 return dbx.TransactionContext(ctx, func(tx *db.Tx) error {
27 lastID, err := datastore.CreateWebhook(ctx, tx, repo.ID(), url, secret, int(contentType), active)
28 if err != nil {
29@@ -120,6 +125,11 @@ func (b *Backend) UpdateWebhook(ctx context.Context, repo proto.Repository, id i
30 dbx := db.FromContext(ctx)
31 datastore := store.FromContext(ctx)
32
33+ // Validate webhook URL to prevent SSRF attacks
34+ if err := webhook.ValidateWebhookURL(url); err != nil {
35+ return err
36+ }
37+
38 return dbx.TransactionContext(ctx, func(tx *db.Tx) error {
39 if err := datastore.UpdateWebhookByID(ctx, tx, repo.ID(), id, url, secret, int(contentType), active); err != nil {
40 return db.WrapError(err)
41diff --git a/pkg/webhook/ssrf_test.go b/pkg/webhook/ssrf_test.go
42new file mode 100644
43index 0000000000000000000000000000000000000000..0928125b30151996b7b68921027ad4bf1d8fa2ea
44--- /dev/null
45+++ b/pkg/webhook/ssrf_test.go
46@@ -0,0 +1,218 @@
47+package webhook
48+
49+import (
50+ "context"
51+ "net/http"
52+ "net/http/httptest"
53+ "testing"
54+ "time"
55+
56+ "github.com/charmbracelet/soft-serve/pkg/db/models"
57+)
58+
59+// TestSSRFProtection tests that the webhook system blocks SSRF attempts.
60+func TestSSRFProtection(t *testing.T) {
61+ tests := []struct {
62+ name string
63+ webhookURL string
64+ shouldBlock bool
65+ description string
66+ }{
67+ {
68+ name: "block localhost",
69+ webhookURL: "http://localhost:8080/webhook",
70+ shouldBlock: true,
71+ description: "should block localhost addresses",
72+ },
73+ {
74+ name: "block 127.0.0.1",
75+ webhookURL: "http://127.0.0.1:8080/webhook",
76+ shouldBlock: true,
77+ description: "should block loopback addresses",
78+ },
79+ {
80+ name: "block 169.254.169.254",
81+ webhookURL: "http://169.254.169.254/latest/meta-data/",
82+ shouldBlock: true,
83+ description: "should block cloud metadata service",
84+ },
85+ {
86+ name: "block private network",
87+ webhookURL: "http://192.168.1.1/webhook",
88+ shouldBlock: true,
89+ description: "should block private networks",
90+ },
91+ {
92+ name: "allow public IP",
93+ webhookURL: "http://8.8.8.8/webhook",
94+ shouldBlock: false,
95+ description: "should allow public IP addresses",
96+ },
97+ }
98+
99+ for _, tt := range tests {
100+ t.Run(tt.name, func(t *testing.T) {
101+ // Create a test webhook
102+ webhook := models.Webhook{
103+ URL: tt.webhookURL,
104+ ContentType: int(ContentTypeJSON),
105+ Secret: "",
106+ }
107+
108+ // Try to send a webhook
109+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
110+ defer cancel()
111+
112+ // Create a simple payload
113+ payload := map[string]string{"test": "data"}
114+
115+ err := sendWebhookWithContext(ctx, webhook, EventPush, payload)
116+
117+ if tt.shouldBlock {
118+ if err == nil {
119+ t.Errorf("%s: expected error but got none", tt.description)
120+ }
121+ } else {
122+ // For public IPs, we expect a connection error (since 8.8.8.8 won't be listening)
123+ // but NOT an SSRF blocking error
124+ if err != nil && isSSRFError(err) {
125+ t.Errorf("%s: should not block public IPs, got: %v", tt.description, err)
126+ }
127+ }
128+ })
129+ }
130+}
131+
132+// TestSecureHTTPClientBlocksRedirects tests that redirects are not followed.
133+func TestSecureHTTPClientBlocksRedirects(t *testing.T) {
134+ // Create a test server on a public-looking address that redirects
135+ redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136+ http.Redirect(w, r, "http://8.8.8.8:8080/safe", http.StatusFound)
137+ }))
138+ defer redirectServer.Close()
139+
140+ // Try to make a request that would redirect
141+ req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, redirectServer.URL, nil)
142+ if err != nil {
143+ t.Fatalf("Failed to create request: %v", err)
144+ }
145+
146diff --git a/pkg/webhook/validator.go b/pkg/webhook/validator.go
147new file mode 100644
148index 0000000000000000000000000000000000000000..1728a14f5f14293a4fd1c3b947e9659fbda19dd5
149--- /dev/null
150+++ b/pkg/webhook/validator.go
151@@ -0,0 +1,163 @@
152+package webhook
153+
154+import (
155+ "errors"
156+ "fmt"
157+ "net"
158+ "net/url"
159+ "slices"
160+ "strings"
161+)
162+
163+var (
164+ // ErrInvalidScheme is returned when the webhook URL scheme is not http or https.
165+ ErrInvalidScheme = errors.New("webhook URL must use http or https scheme")
166+ // ErrPrivateIP is returned when the webhook URL resolves to a private IP address.
167+ ErrPrivateIP = errors.New("webhook URL cannot resolve to private or internal IP addresses")
168+ // ErrInvalidURL is returned when the webhook URL is invalid.
169+ ErrInvalidURL = errors.New("invalid webhook URL")
170+)
171+
172+// ValidateWebhookURL validates that a webhook URL is safe to use.
173+// It checks:
174+// - URL is properly formatted
175+// - Scheme is http or https
176+// - Hostname does not resolve to private/internal IP addresses
177+// - Hostname is not localhost or similar.
178+func ValidateWebhookURL(rawURL string) error {
179+ if rawURL == "" {
180+ return ErrInvalidURL
181+ }
182+
183+ // Parse the URL
184+ u, err := url.Parse(rawURL)
185+ if err != nil {
186+ return fmt.Errorf("%w: %v", ErrInvalidURL, err)
187+ }
188+
189+ // Check scheme
190+ if u.Scheme != "http" && u.Scheme != "https" {
191+ return ErrInvalidScheme
192+ }
193+
194+ // Extract hostname (without port)
195+ hostname := u.Hostname()
196+ if hostname == "" {
197+ return fmt.Errorf("%w: missing hostname", ErrInvalidURL)
198+ }
199+
200+ // Check for localhost variations
201+ if isLocalhost(hostname) {
202+ return ErrPrivateIP
203+ }
204+
205+ // If it's an IP address, validate it directly
206+ if ip := net.ParseIP(hostname); ip != nil {
207+ if isPrivateOrInternalIP(ip) {
208+ return ErrPrivateIP
209+ }
210+ return nil
211+ }
212+
213+ // Resolve hostname to IP addresses
214+ ips, err := net.LookupIP(hostname)
215+ if err != nil {
216+ return fmt.Errorf("%w: cannot resolve hostname: %v", ErrInvalidURL, err)
217+ }
218+
219+ // Check all resolved IPs
220+ if slices.ContainsFunc(ips, isPrivateOrInternalIP) {
221+ return ErrPrivateIP
222+ }
223+
224+ return nil
225+}
226+
227+// isLocalhost checks if the hostname is localhost or similar.
228+func isLocalhost(hostname string) bool {
229+ hostname = strings.ToLower(hostname)
230+ return hostname == "localhost" ||
231+ hostname == "localhost.localdomain" ||
232+ strings.HasSuffix(hostname, ".localhost")
233+}
234+
235+// isPrivateOrInternalIP checks if an IP address is private, internal, or reserved.
236+func isPrivateOrInternalIP(ip net.IP) bool {
237+ // Loopback addresses (127.0.0.0/8, ::1)
238+ if ip.IsLoopback() {
239+ return true
240+ }
241+
242+ // Link-local addresses (169.254.0.0/16, fe80::/10)
243+ // This blocks AWS/GCP/Azure metadata services
244+ if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
245+ return true
246+ }
247+
248+ // Private addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7)
249+ if ip.IsPrivate() {
250+ return true
251diff --git a/pkg/webhook/validator_test.go b/pkg/webhook/validator_test.go
252new file mode 100644
253index 0000000000000000000000000000000000000000..9d4d4f2ac542dbe3d433e16e18c90d78e6236708
254--- /dev/null
255+++ b/pkg/webhook/validator_test.go
256@@ -0,0 +1,315 @@
257+package webhook
258+
259+import (
260+ "net"
261+ "testing"
262+)
263+
264+func TestValidateWebhookURL(t *testing.T) {
265+ tests := []struct {
266+ name string
267+ url string
268+ wantErr bool
269+ errType error
270+ skip string
271+ }{
272+ // Valid URLs (these will perform DNS lookups, so may fail in some environments)
273+ {
274+ name: "valid https URL",
275+ url: "https://1.1.1.1/webhook",
276+ wantErr: false,
277+ },
278+ {
279+ name: "valid http URL",
280+ url: "http://8.8.8.8/webhook",
281+ wantErr: false,
282+ },
283+ {
284+ name: "valid URL with port",
285+ url: "https://1.1.1.1:8080/webhook",
286+ wantErr: false,
287+ },
288+ {
289+ name: "valid URL with path and query",
290+ url: "https://8.8.8.8/webhook?token=abc123",
291+ wantErr: false,
292+ },
293+
294+ // Invalid schemes
295+ {
296+ name: "ftp scheme",
297+ url: "ftp://example.com/webhook",
298+ wantErr: true,
299+ errType: ErrInvalidScheme,
300+ },
301+ {
302+ name: "file scheme",
303+ url: "file:///etc/passwd",
304+ wantErr: true,
305+ errType: ErrInvalidScheme,
306+ },
307+ {
308+ name: "gopher scheme",
309+ url: "gopher://example.com",
310+ wantErr: true,
311+ errType: ErrInvalidScheme,
312+ },
313+ {
314+ name: "no scheme",
315+ url: "example.com/webhook",
316+ wantErr: true,
317+ errType: ErrInvalidScheme,
318+ },
319+
320+ // Localhost variations
321+ {
322+ name: "localhost",
323+ url: "http://localhost/webhook",
324+ wantErr: true,
325+ errType: ErrPrivateIP,
326+ },
327+ {
328+ name: "localhost with port",
329+ url: "http://localhost:8080/webhook",
330+ wantErr: true,
331+ errType: ErrPrivateIP,
332+ },
333+ {
334+ name: "localhost.localdomain",
335+ url: "http://localhost.localdomain/webhook",
336+ wantErr: true,
337+ errType: ErrPrivateIP,
338+ },
339+
340+ // Loopback IPs
341+ {
342+ name: "127.0.0.1",
343+ url: "http://127.0.0.1/webhook",
344+ wantErr: true,
345+ errType: ErrPrivateIP,
346+ },
347+ {
348+ name: "127.0.0.1 with port",
349+ url: "http://127.0.0.1:8080/webhook",
350+ wantErr: true,
351+ errType: ErrPrivateIP,
352+ },
353+ {
354+ name: "127.1.2.3",
355+ url: "http://127.1.2.3/webhook",
356diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go
357index 15d93bb1db3a16485dad1558f516e4a652ae3bb6..9858c52f4afb313ceb845b8d9133c12bd79965e0 100644
358--- a/pkg/webhook/webhook.go
359+++ b/pkg/webhook/webhook.go
360@@ -10,7 +10,9 @@ import (
361 "errors"
362 "fmt"
363 "io"
364+ "net"
365 "net/http"
366+ "time"
367
368 "github.com/charmbracelet/soft-serve/git"
369 "github.com/charmbracelet/soft-serve/pkg/db"
370@@ -36,6 +38,43 @@ type Delivery struct {
371 Event Event
372 }
373
374+// secureHTTPClient creates an HTTP client with SSRF protection.
375+var secureHTTPClient = &http.Client{
376+ Timeout: 30 * time.Second,
377+ Transport: &http.Transport{
378+ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
379+ // Parse the address to get the IP
380+ host, _, err := net.SplitHostPort(addr)
381+ if err != nil {
382+ return nil, err //nolint:wrapcheck
383+ }
384+
385+ // Validate the resolved IP before connecting
386+ ip := net.ParseIP(host)
387+ if ip != nil {
388+ if err := ValidateIPBeforeDial(ip); err != nil {
389+ return nil, fmt.Errorf("blocked connection to private IP: %w", err)
390+ }
391+ }
392+
393+ // Use standard dialer with timeout
394+ dialer := &net.Dialer{
395+ Timeout: 10 * time.Second,
396+ KeepAlive: 30 * time.Second,
397+ }
398+ return dialer.DialContext(ctx, network, addr)
399+ },
400+ MaxIdleConns: 100,
401+ IdleConnTimeout: 90 * time.Second,
402+ TLSHandshakeTimeout: 10 * time.Second,
403+ ExpectContinueTimeout: 1 * time.Second,
404+ },
405+ // Don't follow redirects to prevent bypassing IP validation
406+ CheckRedirect: func(*http.Request, []*http.Request) error {
407+ return http.ErrUseLastResponse
408+ },
409+}
410+
411 // do sends a webhook.
412 // Caller must close the returned body.
413 func do(ctx context.Context, url string, method string, headers http.Header, body io.Reader) (*http.Response, error) {
414@@ -45,7 +84,7 @@ func do(ctx context.Context, url string, method string, headers http.Header, bod
415 }
416
417 req.Header = headers
418- res, err := http.DefaultClient.Do(req)
419+ res, err := secureHTTPClient.Do(req)
420 if err != nil {
421 return nil, err
422 }
423diff --git a/testscript/testdata/repo-webhook-ssrf.txtar b/testscript/testdata/repo-webhook-ssrf.txtar
424new file mode 100644
425index 0000000000000000000000000000000000000000..3ae7e441c9ddb1ae291c510168a9787ef2efef07
426--- /dev/null
427+++ b/testscript/testdata/repo-webhook-ssrf.txtar
428@@ -0,0 +1,49 @@
429+# vi: set ft=conf
430+
431+# Test SSRF protection in webhook creation
432+
433+# start soft serve
434+exec soft serve &
435+# wait for SSH server to start
436+ensureserverrunning SSH_PORT
437+
438+# create a repo
439+soft repo create test-repo
440+stderr 'Created repository test-repo.*'
441+
442+# Try to create webhook with localhost - should fail
443+! soft repo webhook create test-repo http://localhost:8080/webhook -e push
444+stderr 'invalid webhook URL.*private'
445+
446+# Try to create webhook with 127.0.0.1 - should fail
447+! soft repo webhook create test-repo http://127.0.0.1:8080/webhook -e push
448+stderr 'invalid webhook URL.*private'
449+
450+# Try to create webhook with AWS metadata service - should fail
451+! soft repo webhook create test-repo http://169.254.169.254/latest/meta-data/ -e push
452+stderr 'invalid webhook URL.*private'
453+
454+# Try to create webhook with private network - should fail
455+! soft repo webhook create test-repo http://192.168.1.1/webhook -e push
456+stderr 'invalid webhook URL.*private'
457+
458+# Try to create webhook with private 10.x network - should fail
459+! soft repo webhook create test-repo http://10.0.0.1/webhook -e push
460+stderr 'invalid webhook URL.*private'
461+
462+# Create webhook with valid public IP - should succeed
463+new-webhook WH_PUBLIC
464+soft repo webhook create test-repo $WH_PUBLIC -e push
465+! stderr 'invalid webhook URL'
466+
467+# List webhooks - should show only the valid one
468+soft repo webhook list test-repo
469+stdout 'webhook.site'
470+
471+# Try to update webhook to localhost - should fail
472+! soft repo webhook update test-repo 1 --url http://localhost:9090/hook
473+stderr 'invalid webhook URL.*private'
474+
475+# stop the server
476+[windows] stopserver
477+[windows] ! stderr .