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
Carlos Alexandro Becker created
closes GHSA-vwq2-jx9q-9h9f
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
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(-)
@@ -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
@@ -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)
@@ -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)
+ }
+}
@@ -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
+}
@@ -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
+}
@@ -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
}
@@ -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 .