From bb73b9a0eea0d902da4811420535842a4f9aae3b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 10 Nov 2025 10:32:09 -0300 Subject: [PATCH] Merge commit from fork closes GHSA-vwq2-jx9q-9h9f Signed-off-by: Carlos Alexandro Becker --- go.mod | 2 +- pkg/backend/webhooks.go | 10 + pkg/webhook/ssrf_test.go | 218 ++++++++++++++ pkg/webhook/validator.go | 163 ++++++++++ pkg/webhook/validator_test.go | 315 ++++++++++++++++++++ pkg/webhook/webhook.go | 41 ++- testscript/testdata/repo-webhook-ssrf.txtar | 49 +++ 7 files changed, 796 insertions(+), 2 deletions(-) create mode 100644 pkg/webhook/ssrf_test.go create mode 100644 pkg/webhook/validator.go create mode 100644 pkg/webhook/validator_test.go create mode 100644 testscript/testdata/repo-webhook-ssrf.txtar diff --git a/go.mod b/go.mod index 32d153560a1b755d95b46daa41f7f473538c84bc..4f8235f45eba21a0fd0d1b93bf4eeaf70a481a97 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/charmbracelet/soft-serve -go 1.23.0 +go 1.24.0 require ( github.com/alecthomas/chroma/v2 v2.20.0 diff --git a/pkg/backend/webhooks.go b/pkg/backend/webhooks.go index f217c30337f374f7ff0b1aace8c19a86bfb19073..d25d1cf6ec9ddd307d96ba3683ea25ff62882ff6 100644 --- a/pkg/backend/webhooks.go +++ b/pkg/backend/webhooks.go @@ -20,6 +20,11 @@ func (b *Backend) CreateWebhook(ctx context.Context, repo proto.Repository, url datastore := store.FromContext(ctx) url = utils.Sanitize(url) + // Validate webhook URL to prevent SSRF attacks + if err := webhook.ValidateWebhookURL(url); err != nil { + return err //nolint:wrapcheck + } + return dbx.TransactionContext(ctx, func(tx *db.Tx) error { lastID, err := datastore.CreateWebhook(ctx, tx, repo.ID(), url, secret, int(contentType), active) if err != nil { @@ -120,6 +125,11 @@ func (b *Backend) UpdateWebhook(ctx context.Context, repo proto.Repository, id i dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) + // Validate webhook URL to prevent SSRF attacks + if err := webhook.ValidateWebhookURL(url); err != nil { + return err + } + return dbx.TransactionContext(ctx, func(tx *db.Tx) error { if err := datastore.UpdateWebhookByID(ctx, tx, repo.ID(), id, url, secret, int(contentType), active); err != nil { return db.WrapError(err) diff --git a/pkg/webhook/ssrf_test.go b/pkg/webhook/ssrf_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0928125b30151996b7b68921027ad4bf1d8fa2ea --- /dev/null +++ b/pkg/webhook/ssrf_test.go @@ -0,0 +1,218 @@ +package webhook + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/charmbracelet/soft-serve/pkg/db/models" +) + +// TestSSRFProtection tests that the webhook system blocks SSRF attempts. +func TestSSRFProtection(t *testing.T) { + tests := []struct { + name string + webhookURL string + shouldBlock bool + description string + }{ + { + name: "block localhost", + webhookURL: "http://localhost:8080/webhook", + shouldBlock: true, + description: "should block localhost addresses", + }, + { + name: "block 127.0.0.1", + webhookURL: "http://127.0.0.1:8080/webhook", + shouldBlock: true, + description: "should block loopback addresses", + }, + { + name: "block 169.254.169.254", + webhookURL: "http://169.254.169.254/latest/meta-data/", + shouldBlock: true, + description: "should block cloud metadata service", + }, + { + name: "block private network", + webhookURL: "http://192.168.1.1/webhook", + shouldBlock: true, + description: "should block private networks", + }, + { + name: "allow public IP", + webhookURL: "http://8.8.8.8/webhook", + shouldBlock: false, + description: "should allow public IP addresses", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a test webhook + webhook := models.Webhook{ + URL: tt.webhookURL, + ContentType: int(ContentTypeJSON), + Secret: "", + } + + // Try to send a webhook + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // Create a simple payload + payload := map[string]string{"test": "data"} + + err := sendWebhookWithContext(ctx, webhook, EventPush, payload) + + if tt.shouldBlock { + if err == nil { + t.Errorf("%s: expected error but got none", tt.description) + } + } else { + // For public IPs, we expect a connection error (since 8.8.8.8 won't be listening) + // but NOT an SSRF blocking error + if err != nil && isSSRFError(err) { + t.Errorf("%s: should not block public IPs, got: %v", tt.description, err) + } + } + }) + } +} + +// TestSecureHTTPClientBlocksRedirects tests that redirects are not followed. +func TestSecureHTTPClientBlocksRedirects(t *testing.T) { + // Create a test server on a public-looking address that redirects + redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://8.8.8.8:8080/safe", http.StatusFound) + })) + defer redirectServer.Close() + + // Try to make a request that would redirect + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, redirectServer.URL, nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + resp, err := secureHTTPClient.Do(req) + if err != nil { + // httptest.NewServer uses 127.0.0.1, which will be blocked by our SSRF protection + // This is actually correct behavior - we're blocking the initial connection + if !isSSRFError(err) { + t.Fatalf("Request failed with non-SSRF error: %v", err) + } + // Test passed - we blocked the loopback connection + return + } + defer resp.Body.Close() + + // If we got here, check that we got the redirect response (not followed) + if resp.StatusCode != http.StatusFound { + t.Errorf("Expected redirect response (302), got %d", resp.StatusCode) + } +} + +// TestDialContextBlocksPrivateIPs tests the DialContext function directly. +func TestDialContextBlocksPrivateIPs(t *testing.T) { + transport := secureHTTPClient.Transport.(*http.Transport) + + tests := []struct { + name string + addr string + wantErr bool + }{ + {"block loopback", "127.0.0.1:80", true}, + {"block private 10.x", "10.0.0.1:80", true}, + {"block private 192.168.x", "192.168.1.1:80", true}, + {"block link-local", "169.254.169.254:80", true}, + {"allow public IP", "8.8.8.8:80", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + conn, err := transport.DialContext(ctx, "tcp", tt.addr) + if conn != nil { + conn.Close() + } + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error for %s, got none", tt.addr) + } + } else { + // For public IPs, we expect a connection timeout/refused (not an SSRF block) + if err != nil && isSSRFError(err) { + t.Errorf("Should not block %s with SSRF error, got: %v", tt.addr, err) + } + } + }) + } +} + +// sendWebhookWithContext is a test helper that doesn't require database. +func sendWebhookWithContext(ctx context.Context, w models.Webhook, _ Event, _ any) error { + // This is a simplified version for testing that just attempts the HTTP connection + req, err := http.NewRequest("POST", w.URL, nil) + if err != nil { + return err //nolint:wrapcheck + } + req = req.WithContext(ctx) + + resp, err := secureHTTPClient.Do(req) + if resp != nil { + resp.Body.Close() + } + return err //nolint:wrapcheck +} + +// isSSRFError checks if an error is related to SSRF blocking. +func isSSRFError(err error) bool { + if err == nil { + return false + } + errMsg := err.Error() + return contains(errMsg, "private IP") || + contains(errMsg, "blocked connection") || + err == ErrPrivateIP +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || indexOfSubstring(s, substr) >= 0) +} + +func indexOfSubstring(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} + +// TestPrivateIPResolution tests that hostnames resolving to private IPs are blocked. +func TestPrivateIPResolution(t *testing.T) { + // This test verifies that even if a hostname looks public, if it resolves to a private IP, it's blocked + webhook := models.Webhook{ + URL: "http://127.0.0.1:9999/webhook", + ContentType: int(ContentTypeJSON), + } + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + err := sendWebhookWithContext(ctx, webhook, EventPush, map[string]string{"test": "data"}) + if err == nil { + t.Error("Expected error when connecting to loopback address") + return + } + + if !isSSRFError(err) { + t.Errorf("Expected SSRF blocking error, got: %v", err) + } +} diff --git a/pkg/webhook/validator.go b/pkg/webhook/validator.go new file mode 100644 index 0000000000000000000000000000000000000000..1728a14f5f14293a4fd1c3b947e9659fbda19dd5 --- /dev/null +++ b/pkg/webhook/validator.go @@ -0,0 +1,163 @@ +package webhook + +import ( + "errors" + "fmt" + "net" + "net/url" + "slices" + "strings" +) + +var ( + // ErrInvalidScheme is returned when the webhook URL scheme is not http or https. + ErrInvalidScheme = errors.New("webhook URL must use http or https scheme") + // ErrPrivateIP is returned when the webhook URL resolves to a private IP address. + ErrPrivateIP = errors.New("webhook URL cannot resolve to private or internal IP addresses") + // ErrInvalidURL is returned when the webhook URL is invalid. + ErrInvalidURL = errors.New("invalid webhook URL") +) + +// ValidateWebhookURL validates that a webhook URL is safe to use. +// It checks: +// - URL is properly formatted +// - Scheme is http or https +// - Hostname does not resolve to private/internal IP addresses +// - Hostname is not localhost or similar. +func ValidateWebhookURL(rawURL string) error { + if rawURL == "" { + return ErrInvalidURL + } + + // Parse the URL + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("%w: %v", ErrInvalidURL, err) + } + + // Check scheme + if u.Scheme != "http" && u.Scheme != "https" { + return ErrInvalidScheme + } + + // Extract hostname (without port) + hostname := u.Hostname() + if hostname == "" { + return fmt.Errorf("%w: missing hostname", ErrInvalidURL) + } + + // Check for localhost variations + if isLocalhost(hostname) { + return ErrPrivateIP + } + + // If it's an IP address, validate it directly + if ip := net.ParseIP(hostname); ip != nil { + if isPrivateOrInternalIP(ip) { + return ErrPrivateIP + } + return nil + } + + // Resolve hostname to IP addresses + ips, err := net.LookupIP(hostname) + if err != nil { + return fmt.Errorf("%w: cannot resolve hostname: %v", ErrInvalidURL, err) + } + + // Check all resolved IPs + if slices.ContainsFunc(ips, isPrivateOrInternalIP) { + return ErrPrivateIP + } + + return nil +} + +// isLocalhost checks if the hostname is localhost or similar. +func isLocalhost(hostname string) bool { + hostname = strings.ToLower(hostname) + return hostname == "localhost" || + hostname == "localhost.localdomain" || + strings.HasSuffix(hostname, ".localhost") +} + +// isPrivateOrInternalIP checks if an IP address is private, internal, or reserved. +func isPrivateOrInternalIP(ip net.IP) bool { + // Loopback addresses (127.0.0.0/8, ::1) + if ip.IsLoopback() { + return true + } + + // Link-local addresses (169.254.0.0/16, fe80::/10) + // This blocks AWS/GCP/Azure metadata services + if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + + // Private addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7) + if ip.IsPrivate() { + return true + } + + // Unspecified addresses (0.0.0.0, ::) + if ip.IsUnspecified() { + return true + } + + // Multicast addresses + if ip.IsMulticast() { + return true + } + + // Additional checks for IPv4 + if ip4 := ip.To4(); ip4 != nil { + // 0.0.0.0/8 (current network) + if ip4[0] == 0 { + return true + } + // 100.64.0.0/10 (Shared Address Space) + if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 { + return true + } + // 192.0.0.0/24 (IETF Protocol Assignments) + if ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 0 { + return true + } + // 192.0.2.0/24 (TEST-NET-1) + if ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 2 { + return true + } + // 198.18.0.0/15 (benchmarking) + if ip4[0] == 198 && (ip4[1] == 18 || ip4[1] == 19) { + return true + } + // 198.51.100.0/24 (TEST-NET-2) + if ip4[0] == 198 && ip4[1] == 51 && ip4[2] == 100 { + return true + } + // 203.0.113.0/24 (TEST-NET-3) + if ip4[0] == 203 && ip4[1] == 0 && ip4[2] == 113 { + return true + } + // 224.0.0.0/4 (Multicast - already handled by IsMulticast) + // 240.0.0.0/4 (Reserved for future use) + if ip4[0] >= 240 { + return true + } + // 255.255.255.255/32 (Broadcast) + if ip4[0] == 255 && ip4[1] == 255 && ip4[2] == 255 && ip4[3] == 255 { + return true + } + } + + return false +} + +// ValidateIPBeforeDial validates an IP address before establishing a connection. +// This is used to prevent DNS rebinding attacks. +func ValidateIPBeforeDial(ip net.IP) error { + if isPrivateOrInternalIP(ip) { + return ErrPrivateIP + } + return nil +} diff --git a/pkg/webhook/validator_test.go b/pkg/webhook/validator_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9d4d4f2ac542dbe3d433e16e18c90d78e6236708 --- /dev/null +++ b/pkg/webhook/validator_test.go @@ -0,0 +1,315 @@ +package webhook + +import ( + "net" + "testing" +) + +func TestValidateWebhookURL(t *testing.T) { + tests := []struct { + name string + url string + wantErr bool + errType error + skip string + }{ + // Valid URLs (these will perform DNS lookups, so may fail in some environments) + { + name: "valid https URL", + url: "https://1.1.1.1/webhook", + wantErr: false, + }, + { + name: "valid http URL", + url: "http://8.8.8.8/webhook", + wantErr: false, + }, + { + name: "valid URL with port", + url: "https://1.1.1.1:8080/webhook", + wantErr: false, + }, + { + name: "valid URL with path and query", + url: "https://8.8.8.8/webhook?token=abc123", + wantErr: false, + }, + + // Invalid schemes + { + name: "ftp scheme", + url: "ftp://example.com/webhook", + wantErr: true, + errType: ErrInvalidScheme, + }, + { + name: "file scheme", + url: "file:///etc/passwd", + wantErr: true, + errType: ErrInvalidScheme, + }, + { + name: "gopher scheme", + url: "gopher://example.com", + wantErr: true, + errType: ErrInvalidScheme, + }, + { + name: "no scheme", + url: "example.com/webhook", + wantErr: true, + errType: ErrInvalidScheme, + }, + + // Localhost variations + { + name: "localhost", + url: "http://localhost/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + { + name: "localhost with port", + url: "http://localhost:8080/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + { + name: "localhost.localdomain", + url: "http://localhost.localdomain/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + + // Loopback IPs + { + name: "127.0.0.1", + url: "http://127.0.0.1/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + { + name: "127.0.0.1 with port", + url: "http://127.0.0.1:8080/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + { + name: "127.1.2.3", + url: "http://127.1.2.3/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + { + name: "IPv6 loopback", + url: "http://[::1]/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + + // Private IPv4 ranges + { + name: "10.0.0.0", + url: "http://10.0.0.1/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + { + name: "192.168.0.0", + url: "http://192.168.1.1/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + { + name: "172.16.0.0", + url: "http://172.16.0.1/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + { + name: "172.31.255.255", + url: "http://172.31.255.255/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + + // Link-local (AWS/GCP/Azure metadata) + { + name: "AWS metadata service", + url: "http://169.254.169.254/latest/meta-data/", + wantErr: true, + errType: ErrPrivateIP, + }, + { + name: "link-local", + url: "http://169.254.1.1/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + + // Other reserved ranges + { + name: "0.0.0.0", + url: "http://0.0.0.0/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + { + name: "broadcast", + url: "http://255.255.255.255/webhook", + wantErr: true, + errType: ErrPrivateIP, + }, + + // Invalid URLs + { + name: "empty URL", + url: "", + wantErr: true, + errType: ErrInvalidURL, + }, + { + name: "missing hostname", + url: "http:///webhook", + wantErr: true, + errType: ErrInvalidURL, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.skip != "" { + t.Skip(tt.skip) + } + err := ValidateWebhookURL(tt.url) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateWebhookURL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr && tt.errType != nil { + if !isErrorType(err, tt.errType) { + t.Errorf("ValidateWebhookURL() error = %v, want error type %v", err, tt.errType) + } + } + }) + } +} + +func TestIsPrivateOrInternalIP(t *testing.T) { + tests := []struct { + name string + ip string + isPriv bool + }{ + // Public IPs + {"Google DNS", "8.8.8.8", false}, + {"Cloudflare DNS", "1.1.1.1", false}, + {"Public IPv6", "2001:4860:4860::8888", false}, + + // Loopback + {"127.0.0.1", "127.0.0.1", true}, + {"127.1.2.3", "127.1.2.3", true}, + {"::1", "::1", true}, + + // Private ranges + {"10.0.0.1", "10.0.0.1", true}, + {"192.168.1.1", "192.168.1.1", true}, + {"172.16.0.1", "172.16.0.1", true}, + {"172.31.255.255", "172.31.255.255", true}, + + // Link-local + {"169.254.169.254", "169.254.169.254", true}, + {"169.254.1.1", "169.254.1.1", true}, + {"fe80::1", "fe80::1", true}, + + // Other reserved + {"0.0.0.0", "0.0.0.0", true}, + {"255.255.255.255", "255.255.255.255", true}, + {"240.0.0.1", "240.0.0.1", true}, + + // Shared address space + {"100.64.0.1", "100.64.0.1", true}, + {"100.127.255.255", "100.127.255.255", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ip := net.ParseIP(tt.ip) + if ip == nil { + t.Fatalf("Failed to parse IP: %s", tt.ip) + } + if got := isPrivateOrInternalIP(ip); got != tt.isPriv { + t.Errorf("isPrivateOrInternalIP(%s) = %v, want %v", tt.ip, got, tt.isPriv) + } + }) + } +} + +func TestIsLocalhost(t *testing.T) { + tests := []struct { + name string + hostname string + want bool + }{ + {"localhost", "localhost", true}, + {"LOCALHOST", "LOCALHOST", true}, + {"localhost.localdomain", "localhost.localdomain", true}, + {"test.localhost", "test.localhost", true}, + {"example.com", "example.com", false}, + {"localhos", "localhos", false}, + {"localhost.com", "localhost.com", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isLocalhost(tt.hostname); got != tt.want { + t.Errorf("isLocalhost(%s) = %v, want %v", tt.hostname, got, tt.want) + } + }) + } +} + +func TestValidateIPBeforeDial(t *testing.T) { + tests := []struct { + name string + ip string + wantErr bool + }{ + {"public IP", "8.8.8.8", false}, + {"private IP", "192.168.1.1", true}, + {"loopback", "127.0.0.1", true}, + {"link-local", "169.254.169.254", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ip := net.ParseIP(tt.ip) + if ip == nil { + t.Fatalf("Failed to parse IP: %s", tt.ip) + } + err := ValidateIPBeforeDial(ip) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateIPBeforeDial(%s) error = %v, wantErr %v", tt.ip, err, tt.wantErr) + } + }) + } +} + +// isErrorType checks if err is or wraps errType. +func isErrorType(err, errType error) bool { + if err == errType { + return true + } + // Check if err wraps errType + for err != nil { + if err == errType { + return true + } + unwrapped, ok := err.(interface{ Unwrap() error }) + if !ok { + break + } + err = unwrapped.Unwrap() + } + return false +} diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go index 15d93bb1db3a16485dad1558f516e4a652ae3bb6..9858c52f4afb313ceb845b8d9133c12bd79965e0 100644 --- a/pkg/webhook/webhook.go +++ b/pkg/webhook/webhook.go @@ -10,7 +10,9 @@ import ( "errors" "fmt" "io" + "net" "net/http" + "time" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/db" @@ -36,6 +38,43 @@ type Delivery struct { Event Event } +// secureHTTPClient creates an HTTP client with SSRF protection. +var secureHTTPClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + // Parse the address to get the IP + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, err //nolint:wrapcheck + } + + // Validate the resolved IP before connecting + ip := net.ParseIP(host) + if ip != nil { + if err := ValidateIPBeforeDial(ip); err != nil { + return nil, fmt.Errorf("blocked connection to private IP: %w", err) + } + } + + // Use standard dialer with timeout + dialer := &net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + } + return dialer.DialContext(ctx, network, addr) + }, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + // Don't follow redirects to prevent bypassing IP validation + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, +} + // do sends a webhook. // Caller must close the returned body. func do(ctx context.Context, url string, method string, headers http.Header, body io.Reader) (*http.Response, error) { @@ -45,7 +84,7 @@ func do(ctx context.Context, url string, method string, headers http.Header, bod } req.Header = headers - res, err := http.DefaultClient.Do(req) + res, err := secureHTTPClient.Do(req) if err != nil { return nil, err } diff --git a/testscript/testdata/repo-webhook-ssrf.txtar b/testscript/testdata/repo-webhook-ssrf.txtar new file mode 100644 index 0000000000000000000000000000000000000000..3ae7e441c9ddb1ae291c510168a9787ef2efef07 --- /dev/null +++ b/testscript/testdata/repo-webhook-ssrf.txtar @@ -0,0 +1,49 @@ +# vi: set ft=conf + +# Test SSRF protection in webhook creation + +# start soft serve +exec soft serve & +# wait for SSH server to start +ensureserverrunning SSH_PORT + +# create a repo +soft repo create test-repo +stderr 'Created repository test-repo.*' + +# Try to create webhook with localhost - should fail +! soft repo webhook create test-repo http://localhost:8080/webhook -e push +stderr 'invalid webhook URL.*private' + +# Try to create webhook with 127.0.0.1 - should fail +! soft repo webhook create test-repo http://127.0.0.1:8080/webhook -e push +stderr 'invalid webhook URL.*private' + +# Try to create webhook with AWS metadata service - should fail +! soft repo webhook create test-repo http://169.254.169.254/latest/meta-data/ -e push +stderr 'invalid webhook URL.*private' + +# Try to create webhook with private network - should fail +! soft repo webhook create test-repo http://192.168.1.1/webhook -e push +stderr 'invalid webhook URL.*private' + +# Try to create webhook with private 10.x network - should fail +! soft repo webhook create test-repo http://10.0.0.1/webhook -e push +stderr 'invalid webhook URL.*private' + +# Create webhook with valid public IP - should succeed +new-webhook WH_PUBLIC +soft repo webhook create test-repo $WH_PUBLIC -e push +! stderr 'invalid webhook URL' + +# List webhooks - should show only the valid one +soft repo webhook list test-repo +stdout 'webhook.site' + +# Try to update webhook to localhost - should fail +! soft repo webhook update test-repo 1 --url http://localhost:9090/hook +stderr 'invalid webhook URL.*private' + +# stop the server +[windows] stopserver +[windows] ! stderr .