GitHub Security Advisory: GHSA-6jxm-fv7w-rw5j
Package: github.com/axllent/mailpit (GO)
Severity: MODERATE (CVSS 5.8)
CWE: CWE-918: Server-Side Request Forgery (SSRF)
Vulnerable versions: < 1.28.3
Patched in: 1.28.3

Summary: Mailpit has a Server-Side Request Forgery (SSRF) via HTML Check API

Details:
### Server-Side Request Forgery (SSRF) via HTML Check CSS Download

The HTML Check feature (`/api/v1/message/{ID}/html-check`) is designed to analyze HTML emails for compatibility. During this process, the `inlineRemoteCSS()` function automatically downloads CSS files from external `<link rel="stylesheet" href="...">` tags to inline them for testing. 


#### Affected Components

- **Primary File:** `internal/htmlcheck/css.go` (lines 132-207)
- **API Endpoint:** `/api/v1/message/{ID}/html-check`
- **Handler:** `server/apiv1/other.go` (lines 38-75)
- **Vulnerable Functions:**
  - `inlineRemoteCSS()` - line 132
  - `downloadToBytes()` - line 193
  - `isURL()` - line 221

#### Technical Details

**1. Insufficient URL Validation (`isURL()` function):**

```go
// internal/htmlcheck/css.go:221-224
func isURL(str string) bool {
    u, err := url.Parse(str)
    return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
```


**2. Unrestricted Download (`downloadToBytes()` function):**

```go
// internal/htmlcheck/css.go:193-207
func downloadToBytes(url string) ([]byte, error) {
    client := http.Client{
        Timeout: 5 * time.Second,
    }

    // Get the link response data
    resp, err := client.Get(url)  // ⚠️ VULNERABLE - No IP validation
    if err != nil {
        return nil, err
    }
    defer func() { _ = resp.Body.Close() }()

    if resp.StatusCode != 200 {
        err := fmt.Errorf("error downloading %s", url)
        return nil, err
    }

    body, err := io.ReadAll(resp.Body)  // ⚠️ Downloads ENTIRE response
    if err != nil {
        return nil, err
    }

    return body, nil
}
```

**3. Automatic CSS Processing:**

```go
// internal/htmlcheck/css.go:132-187
func inlineRemoteCSS(h string) (string, error) {
    reader := strings.NewReader(h)
    doc, err := goquery.NewDocumentFromReader(reader)
    if err != nil {
        return h, err
    }

    remoteCSS := doc.Find("link[rel=\"stylesheet\"]").Nodes
    for _, link := range remoteCSS {
        attributes := link.Attr
        for _, a := range attributes {
            if a.Key == "href" {
                if !isURL(a.Val) {  // ⚠️ Insufficient validation
                    continue
                }

                if config.BlockRemoteCSSAndFonts {
                    logger.Log().Debugf("[html-check] skip testing remote CSS content: %s (--block-remote-css-and-fonts)", a.Val)
                    return h, nil
                }

                resp, err := downloadToBytes(a.Val)  // ⚠️ Downloads from ANY URL
                if err != nil {
                    logger.Log().Warnf("[html-check] failed to download %s", a.Val)
                    continue
                }

                // Inlines the downloaded CSS
                styleBlock := &html.Node{
                    Type:     html.ElementNode,
                    Data:     "style",
                    DataAtom: atom.Style,
                }
                styleBlock.AppendChild(&html.Node{