GitHub Advisoryadvisory · Go
GHSA-w4mc-hhc6-xp28 Mailpit: Incomplete SSRF protection in Link Check API via IPv6 transition mechanisms
MEDIUM
Official page ↗
- Aliases
- CVE-2026-55187
- Ecosystem
- Go
- Packages
- github.com/axllent/mailpit
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:N
- Weaknesses
- CWE-918
- Reviewed
- 2026-06-19
Affected packages
• Go github.com/axllent/mailpit: < 1.30.2 → fixed in 1.30.2
Description
Summary
The remediation shipped in mailpit v1.29.2 for GHSA-mpf7-p9x7-96r3 (CVE-2026-27808) is incomplete. The `tools.IsInternalIP` deny-list relies on Go's stdlib classification helpers (`IsLoopback`, `IsPrivate`, `IsLinkLocalUnicast`, `IsLinkLocalMulticast`, `IsUnspecified`, `IsMulticast`) plus an inline CGNAT range, but those helpers do not match two classes of IPv6 address that should be blocked for SSRF purposes:
1. IPv6 forms that embed an IPv4 destination via documented translation mechanisms — 6to4, NAT64, IPv4-compatible IPv6, ISATAP, or (in older Go versions) IPv4-mapped IPv6. These let an attacker reach internal IPv4 destinations by supplying an IPv6 literal that encodes the desired IPv4.
2. IPv6 prefixes that fall outside the narrow private/loopback/link-local ranges Go's stdlib classifies — specifically the deprecated site-local prefix `fec0::/10` (RFC 3879/4291) and the documentation prefix `2001:db8::/32` (RFC 3849). The first is still routable on dual-stack hosts and is cited as a bypass form in CVE-2026-44430; the second should never appear in real network traffic and is safe to block as fail-safe behavior.
Together these gaps let the Link Check API be coerced into dialing internal destinations that the v1.29.2 fix was intended to block.
This is the same bug class as GHSA-56c3-vfp2-5qqj / CVE-2026-44430 (MCP Registry) and GHSA-86m8-88fq-xfxp / CVE-2026-45741 (Gotenberg) — projects that, like mailpit, built their SSRF deny-list around Go's stdlib `Is*` family and discovered the resulting bypass post-disclosure.
The underlying ecosystem-wide issue is tracked upstream at golang/go#79925, which proposes extending `net.IP.IsPrivate` to handle these IPv6 transition forms. Until that lands, every Go project that wants comprehensive SSRF protection has to implement the decoding itself — which is exactly the gap that produced this advisory and the three CVEs in adjacent projects cited above.
Affected versions
• mailpit `v1.29.2` and later HEAD — the GHSA-mpf7-p9x7-96r3 fix is in place but `tools.IsInternalIP` does not cover the IPv6 forms enumerated below.
• Pre-`v1.29.2` versions remain vulnerable to the original advisory.
Vulnerable code
`internal/tools/net.go` L25-L34 — `IsInternalIP`:
```go
func IsInternalIP(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsUnspecified() ||
ip.IsMulticast() ||
cgnatRange.Contains(ip)
}
```
`internal/linkcheck/status.go` L140-L163 — `safeDialContext` calls `IsInternalIP` on resolved IPs before dialing, but only blocks when one of the seven predicates above fires.
For each of the following bypass forms, `net.IP.IsLoopback`, `IsPrivate`, `IsLinkLocalUnicast`, `IsLinkLocalMulticast`, `IsUnspecified`, `IsMulticast`, and the CGNAT range check all return `false` — so the dial proceeds:
IPv4-embedded-in-IPv6 forms (each carries an IPv4 destination via a documented translation prefix):
| Bypass IPv6 literal | Decoded IPv4 destination | RFC |
|---|---|---|
| `64:ff9b::a9fe:a9fe` | `169.254.169.254` (AWS / GCP / Azure metadata) | RFC 6052 — NAT64 well-known prefix |
| `64:ff9b:1::a9fe:a9fe` | `169.254.169.254` | RFC 8215 — NAT64 local-use |
| `2002:a9fe:a9fe::` | `169.254.169.254` | RFC 3056 — 6to4 |
| `::a9fe:a9fe` | `169.254.169.254` | RFC 4291 §2.5.5.1 — IPv4-compatible IPv6 |
| `64:ff9b::7f00:1` | `127.0.0.1` | RFC 6052 (loopback via NAT64) |
| `2002:0a00:0001::` | `10.0.0.1` | RFC 3056 (RFC 1918 via 6to4) |
| `:5efe:` | `` (e.g. `2001:db8::5efe:7f00:1` → `127.0.0.1`) | RFC 5214 — ISATAP |
**Direct IPv6 prefixes not classified by the stdlib `Is*` family:**
| Bypass IPv6 literal | What it is | RFC |
|---|---|---|
| `fec0::1` (any address in `fec0::/10`) | Deprecated site-local — still routable on dual-stack hosts | RFC 3879 (deprecation) / RFC 4291 §2.5.7 |
| `2001:db8::1` (any address in `2001:db8::/32`) | Documentation prefix — should never appear on the wire | RFC 3849 |
`IsInternalIP` returns `false` for every entry in both tables.
The original advisory's stated mitigations *do* hold against the embedded-IPv4 forms in the narrow case where the IPv6 literal is `::ffff:` (IPv4-mapped), because Go's `net.IP.To4()` normalizes that form and the stdlib `Is*` methods then check the embedded IPv4. This was the partial fix shipped in Go 1.22.4 / CVE-2024-24790. But it does not extend to 6to4, NAT64, IPv4-compatible, or ISATAP forms — those require explicit decoding that neither Go's stdlib nor `IsInternalIP` performs. The direct prefixes (`fec0::/10`, `2001:db8::/32`) likewise are simply outside the scope of any Go stdlib `Is*` method.
Proof of Concept
The repro depends on environment-specific routing for the embedded IPv4 destination. The forms below all *pass* the `safeDialContext` check on a stock mailpit v1.29.2 — they will not be blocked by the SSRF deny-list. Whether they connect successfully depends on whether the host's network has NAT64 / 6to4 routing to reach the embedded IPv4.
Unit-test repro (no network dependency)
The most defensible PoC is a unit test against `IsInternalIP` itself — it demonstrates the deny-list gap directly without depending on the test environment routing the bypass IPs:
```go
// internal/tools/net_ssrf_test.go
package tools
import (
"net"
"testing"
)
func TestIsInternalIP_UncoveredIPv6Forms(t *testing.T) {
cases := map[string]net.IP{
// IPv4-embedded-in-IPv6 forms.
"NAT64 well-known wrapping AWS IMDS (RFC 6052)": net.ParseIP("64:ff9b::a9fe:a9fe"),
"NAT64 local-use wrapping AWS IMDS (RFC 8215)": net.ParseIP("64:ff9b:1::a9fe:a9fe"),
"6to4 wrapping AWS IMDS (RFC 3056)": net.ParseIP("2002:a9fe:a9fe::"),
"IPv4-compatible IPv6 wrapping AWS IMDS (RFC 4291)": net.ParseIP("::a9fe:a9fe"),
"NAT64 wrapping loopback (RFC 6052)": net.ParseIP("64:ff9b::7f00:1"),
"6to4 wrapping RFC 1918 (RFC 3056)": net.ParseIP("2002:0a00:0001::"),
"ISATAP wrapping AWS IMDS (RFC 5214)": net.ParseIP("2001:db8::5efe:a9fe:a9fe"),
// Direct IPv6 prefixes outside the stdlib Is* family.
"Deprecated site-local fec0::/10 (RFC 3879/4291)": net.ParseIP("fec0::1"),
"Documentation prefix 2001:db8::/32 (RFC 3849)": net.ParseIP("2001:db8::1"),
}
for name, ip := range cases {
t.Run(name, func(t *testing.T) {
if !IsInternalIP(ip) {
t.Errorf("IsInternalIP(%s) = false — SSRF deny-list bypass", ip)
}
})
}
}
```
Run with:
```
go test ./internal/tools/ -run TestIsInternalIP_UncoveredIPv6Forms
```
On v1.29.2 every subtest fails. Each failure is a documented bypass.
End-to-end repro
In an environment where the embedded IPv4 destination is reachable (e.g. a host whose network provides NAT64 to RFC 1918 / link-local):
1. Send a crafted email to mailpit's SMTP listener containing an `` with a bypass URL:
```html
link
…
Related
Alias · CVE (1)
- CVE-2026-55187 Mailpit is an email testing tool and API for developers. vulnerability
Weakness · CWE (1)
- CWE-918 Server-Side Request Forgery weakness
References
- https://github.com/axllent/mailpit/security/advisories/GHSA-w4mc-hhc6-xp28 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-55187 advisory
- https://github.com/axllent/mailpit/commit/a88dadbbe1df016a35a445089ef2a362a6c2de78 web
- https://github.com/axllent/mailpit package
- https://github.com/axllent/mailpit/releases/tag/v1.30.2 web