diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 80620ce..cb2eac7 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -16,3 +16,7 @@ **Vulnerability:** Writing fully buffered in-memory data to temporary disk files solely for parsing. **Learning:** This increases attack surface, risks disk exhaustion, and violates the principle of least privilege. **Prevention:** Refactor parsing functions to accept byte slices or `io.Reader` directly to process data in memory. +## 2024-05-24 - SSRF vulnerability in plugin downloads +**Vulnerability:** The application used `http.Get` directly for downloading plugins from potentially user-controlled URLs without IP validation, exposing it to Server-Side Request Forgery (SSRF) attacks, specifically against cloud metadata endpoints like `169.254.169.254`. +**Learning:** `http.Get` does not protect against SSRF. Merely parsing URLs is insufficient since DNS rebinding can still target internal IPs. +**Prevention:** Use a custom `http.Client` with a `net.Dialer` and a `Control` hook to intercept socket connections *after* DNS resolution but *before* connecting, blocking prohibited IPs (e.g., `169.254.169.254`). Also, ensure proper timeouts are configured. diff --git a/internal/plugin/manager.go b/internal/plugin/manager.go index 7aad027..7a6cc11 100644 --- a/internal/plugin/manager.go +++ b/internal/plugin/manager.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -16,6 +17,7 @@ import ( "runtime" "sort" "strings" + "syscall" "time" "github.com/himattm/prism/internal/fsutil" @@ -24,6 +26,27 @@ import ( var metadataRegex = regexp.MustCompile(`^#\s*@(\w+[-\w]*)\s+(.+)$`) var versionRegex = regexp.MustCompile(`(?m)^#\s*@version\s+(.+)$`) +func newSafeHTTPClient() *http.Client { + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Control: func(network, address string, c syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err == nil { + host = strings.SplitN(host, "%", 2)[0] + if ip := net.ParseIP(host); ip != nil && ip.String() == "169.254.169.254" { + return fmt.Errorf("blocked cloud metadata IP") + } + } + return nil + }, + } + t := http.DefaultTransport.(*http.Transport).Clone() + t.DialContext = dialer.DialContext + t.DisableKeepAlives = true + return &http.Client{Timeout: 10 * time.Second, Transport: t} +} + // sanitizeFilename ensures a filename cannot be used for path traversal func sanitizeFilename(name string) string { return filepath.Base(filepath.Clean("/" + name)) @@ -410,7 +433,8 @@ func (m *Manager) addScriptPlugin(owner, repo, pluginName string) error { fmt.Printf("Fetching script from: %s\n", rawURL) - resp, err := http.Get(rawURL) + client := newSafeHTTPClient() + resp, err := client.Get(rawURL) if err != nil { return fmt.Errorf("failed to fetch plugin: %w", err) } @@ -468,7 +492,8 @@ func (m *Manager) addFromDirectURL(rawURL string) error { return fmt.Errorf("unsupported URL scheme: %s", parsedURL.Scheme) } - resp, err := http.Get(parsedURL.String()) + client := newSafeHTTPClient() + resp, err := client.Get(parsedURL.String()) if err != nil { return fmt.Errorf("failed to fetch plugin: %w", err) }