Mailpit is Vulnerable to Server-Side Request Forgery (SSRF) via Link Check API
Description
Mailpit is an email testing tool and API for developers. Prior to version 1.29.2, 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. Version 1.29.2 fixes this vulnerability.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/axllent/mailpitGo | < 1.29.2 | 1.29.2 |
Affected products
1Patches
110ad4df8cc0cSecurity: Prevent Server-Side Request Forgery (SSRF) via Link Check API ([GHSA-mpf7-p9x7-96r3](https://github.com/axllent/mailpit/security/advisories/GHSA-mpf7-p9x7-96r3))
5 files changed · +141 −13
cmd/root.go+4 −0 modified@@ -105,6 +105,7 @@ func init() { rootCmd.Flags().StringVar(&config.UITLSKey, "ui-tls-key", config.UITLSKey, "TLS key for web UI (HTTPS) - requires ui-tls-cert") rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set CORS origin(s) for the API, comma-separated (eg: example.com,foo.com)") rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts") + rootCmd.Flags().BoolVar(&config.AllowInternalHTTPRequests, "allow-internal-http-requests", config.AllowInternalHTTPRequests, "Allow link-checker & screenshots to access internal IP addresses") rootCmd.Flags().StringVar(&config.EnableSpamAssassin, "enable-spamassassin", config.EnableSpamAssassin, "Enable integration with SpamAssassin") rootCmd.Flags().BoolVar(&config.AllowUntrustedTLS, "allow-untrusted-tls", config.AllowUntrustedTLS, "Do not verify HTTPS certificates (link checker & screenshots)") rootCmd.Flags().BoolVar(&config.DisableHTTPCompression, "disable-http-compression", config.DisableHTTPCompression, "Disable HTTP compression support (web UI & API)") @@ -250,6 +251,9 @@ func initConfigFromEnv() { if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") { config.BlockRemoteCSSAndFonts = true } + if getEnabledFromEnv("MP_ALLOW_INTERNAL_HTTP_REQUESTS") { + config.AllowInternalHTTPRequests = true + } if len(os.Getenv("MP_ENABLE_SPAMASSASSIN")) > 0 { config.EnableSpamAssassin = os.Getenv("MP_ENABLE_SPAMASSASSIN") }
config/config.go+4 −0 modified@@ -127,6 +127,10 @@ var ( // BlockRemoteCSSAndFonts used to disable remote CSS & fonts BlockRemoteCSSAndFonts = false + // AllowInternalHTTPRequests will allow HTTP requests to internal IP addresses (e.g., loopback, private, link-local, or multicast) when set to true. + // This policy applies to both link checking and screenshot generation (proxy) features and is disabled by default for security reasons. + AllowInternalHTTPRequests = false + // CLITagsArg is used to map the CLI args CLITagsArg string
internal/linkcheck/status.go+56 −8 modified@@ -1,14 +1,20 @@ package linkcheck import ( + "context" "crypto/tls" + "errors" + "fmt" + "net" "net/http" "regexp" + "strings" "sync" "time" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" + "github.com/axllent/mailpit/internal/tools" ) func getHTTPStatuses(links []string, followRedirects bool) []Link { @@ -34,6 +40,10 @@ func getHTTPStatuses(links []string, followRedirects bool) []Link { if err != nil { l.StatusCode = 0 l.Status = httpErrorSummary(err) + if strings.Contains(l.Status, "private/reserved address") { + l.Status = "Blocked private/reserved address" + l.StatusCode = 451 + } } else { l.StatusCode = code l.Status = http.StatusText(code) @@ -57,23 +67,37 @@ func getHTTPStatuses(links []string, followRedirects bool) []Link { // Do a HEAD request to return HTTP status code func doHead(link string, followRedirects bool) (int, error) { + if !tools.IsValidLinkURL(link) { + return 0, fmt.Errorf("invalid URL: %s", link) + } - timeout := time.Duration(10 * time.Second) + dialer := &net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + } - tr := &http.Transport{} + tr := &http.Transport{ + DialContext: safeDialContext(dialer), + } if config.AllowUntrustedTLS { tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec } client := http.Client{ - Timeout: timeout, + Timeout: 10 * time.Second, Transport: tr, CheckRedirect: func(req *http.Request, via []*http.Request) error { - if followRedirects { - return nil + if len(via) >= 3 { + return errors.New("too many redirects") + } + if !followRedirects { + return http.ErrUseLastResponse + } + if !tools.IsValidLinkURL(req.URL.String()) { + return fmt.Errorf("blocked redirect to invalid URL: %s", req.URL) } - return http.ErrUseLastResponse + return nil }, } @@ -92,7 +116,6 @@ func doHead(link string, followRedirects bool) (int, error) { } return 0, err - } return res.StatusCode, nil @@ -107,8 +130,33 @@ func httpErrorSummary(err error) string { if !re.MatchString(e) { return e } - parts := re.FindAllStringSubmatch(e, -1) return parts[0][len(parts[0])-1] } + +// SafeDialContext is a custom dialer that checks if the resolved IP addresses are internal before allowing the connection. +func safeDialContext(dialer *net.Dialer) func(ctx context.Context, network, address string) (net.Conn, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + + if !config.AllowInternalHTTPRequests { + for _, ip := range ips { + if tools.IsInternalIP(ip.IP) { + logger.Log().Warnf("[link-check] Blocked HEAD request to private/reserved address: %s (%s)", host, ip) + return nil, fmt.Errorf("blocked request to %s (%s): private/reserved address", host, ip) + } + } + } + + return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port)) + } +}
internal/tools/net.go+28 −0 added@@ -0,0 +1,28 @@ +package tools + +import ( + "net" + "net/url" +) + +// IsInternalIP checks if the given IP address is an internal IP address (e.g., loopback, private, link-local, or multicast). +// IsLoopback — 127.0.0.0/8, ::1 +// IsPrivate — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 +// IsLinkLocalUnicast — 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254) +// IsLinkLocalMulticast — 224.0.0.0/24, ff02::/16 +// IsUnspecified — 0.0.0.0, :: +// IsMulticast — 224.0.0.0/4, ff00::/8 +func IsInternalIP(ip net.IP) bool { + return ip.IsLoopback() || + ip.IsPrivate() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsUnspecified() || + ip.IsMulticast() +} + +// IsValidLinkURL checks if the provided string is a valid URL with http or https scheme and a non-empty hostname. +func IsValidLinkURL(str string) bool { + u, err := url.Parse(str) + return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" +}
server/handlers/proxy.go+49 −5 modified@@ -2,10 +2,13 @@ package handlers import ( + "context" "crypto/tls" "encoding/base64" + "errors" "fmt" "io" + "net" "net/http" "net/url" "regexp" @@ -96,21 +99,37 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) { return } - if !linkRe.MatchString(uri) { - logger.Log().Warnf("[proxy] invalid request %s", uri) - httpError(w, "Error: invalid request") + if !linkRe.MatchString(uri) || !tools.IsValidLinkURL(uri) { + logger.Log().Warnf("[proxy] invalid URL %s", uri) + httpError(w, "Error: invalid URL") return } - tr := &http.Transport{} + dialer := &net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + } + + tr := &http.Transport{ + DialContext: safeDialContext(dialer), + } if config.AllowUntrustedTLS { tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec } client := &http.Client{ - Transport: tr, Timeout: 10 * time.Second, + Transport: tr, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return errors.New("too many redirects") + } + if !tools.IsValidLinkURL(req.URL.String()) { + return fmt.Errorf("blocked redirect to invalid URL: %s", req.URL) + } + return nil + }, } req, err := http.NewRequest("GET", uri, nil) @@ -357,3 +376,28 @@ func supportedProxyContentType(ct string) bool { return false } + +// SafeDialContext is a custom dialer that checks if the resolved IP addresses are internal before allowing the connection. +func safeDialContext(dialer *net.Dialer) func(ctx context.Context, network, address string) (net.Conn, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + + if !config.AllowInternalHTTPRequests { + for _, ip := range ips { + if tools.IsInternalIP(ip.IP) { + return nil, fmt.Errorf("blocked request to %s (%s): private/reserved address", host, ip) + } + } + } + + return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port)) + } +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-mpf7-p9x7-96r3ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27808ghsaADVISORY
- github.com/axllent/mailpit/commit/10ad4df8cc0cd9e51dea1b4410009545eef7fbf5ghsax_refsource_MISCWEB
- github.com/axllent/mailpit/releases/tag/v1.29.2ghsax_refsource_MISCWEB
- github.com/axllent/mailpit/security/advisories/GHSA-mpf7-p9x7-96r3ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.