VYPR
Critical severityNVD Advisory· Published Nov 10, 2025· Updated Nov 12, 2025

Soft Serve is vulnerable to SSRF through its Webhooks

CVE-2025-64522

Description

Soft Serve is a self-hostable Git server for the command line. Versions prior to 0.11.1 have a SSRF vulnerability where webhook URLs are not validated, allowing repository administrators to create webhooks targeting internal services, private networks, and cloud metadata endpoints. Version 0.11.1 fixes the vulnerability.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/charmbracelet/soft-serveGo
< 0.11.10.11.1

Affected products

1

Patches

1
bb73b9a0eea0

Merge commit from fork

https://github.com/charmbracelet/soft-serveCarlos Alexandro BeckerNov 10, 2025via ghsa
7 files changed · +796 2
  • go.mod+1 1 modified
    @@ -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
    
  • pkg/backend/webhooks.go+10 0 modified
    @@ -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)
    
  • pkg/webhook/ssrf_test.go+218 0 added
    @@ -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)
    +	}
    +}
    
  • pkg/webhook/validator.go+163 0 added
    @@ -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
    +}
    
  • pkg/webhook/validator_test.go+315 0 added
    @@ -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
    +}
    
  • pkg/webhook/webhook.go+40 1 modified
    @@ -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
     	}
    
  • testscript/testdata/repo-webhook-ssrf.txtar+49 0 added
    @@ -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 .
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.