Mailpit Proxy Endpoint is Vulnerable to Server-Side Request Forgery (SSRF)
Description
Mailpit is an email testing tool and API for developers. Versions 1.28.0 and below have a Server-Side Request Forgery (SSRF) vulnerability in the /proxy endpoint, allowing attackers to make requests to internal network resources. The /proxy endpoint validates http:// and https:// schemes, but it does not block internal IP addresses, enabling attackers to access internal services and APIs. This vulnerability is limited to HTTP GET requests with minimal headers. The issue is fixed in version 1.28.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/axllent/mailpitGo | < 1.28.1 | 1.28.1 |
Affected products
1Patches
13b9b470c093bSecurity: Restrict screenshot proxy to only support asset links contained in messages [CVE-2026-21859](https://github.com/axllent/mailpit/security/advisories/GHSA-8v65-47jx-7mfr)
2 files changed · +244 −32
server/handlers/proxy.go+218 −17 modified@@ -3,32 +3,102 @@ package handlers import ( "crypto/tls" + "encoding/base64" "fmt" "io" "net/http" "net/url" "regexp" "strings" + "sync" "time" + "github.com/PuerkitoBio/goquery" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" + "github.com/axllent/mailpit/internal/storage" + "github.com/axllent/mailpit/internal/tools" ) -var linkRe = regexp.MustCompile(`(?i)^https?:\/\/`) +var ( + linkRe = regexp.MustCompile(`(?i)^https?:\/\/`) -// ProxyHandler is used to proxy assets for printing + urlRe = regexp.MustCompile(`(?mU)url\(('|")?(https?:\/\/[^)'"]+)('|")?\)`) + + assetsMutex sync.Mutex + + assets = map[string]MessageAssets{} +) + +// MessageAssets represents assets linked in a message +type MessageAssets struct { + ID string + // Created timestamp so we can expire old entries + Created time.Time + // Assets found in the message + Assets []string +} + +func init() { + // Start a goroutine to clean up old asset entries every minute + go func() { + for { + time.Sleep(time.Minute) + assetsMutex.Lock() + now := time.Now() + for id, entry := range assets { + if now.Sub(entry.Created) > time.Minute { + logger.Log().Debugf("[proxy] cleaning up assets for message %s", id) + delete(assets, id) + } + } + assetsMutex.Unlock() + } + }() +} + +// ProxyHandler is used to proxy assets for printing. +// It accepts a base64-encoded message-id:url string as the `data` query parameter. func ProxyHandler(w http.ResponseWriter, r *http.Request) { - uri := strings.TrimSpace(r.URL.Query().Get("url")) - if uri == "" { - logger.Log().Warn("[proxy] URL missing") - httpError(w, "Error: URL missing") + encoded := strings.TrimSpace(r.URL.Query().Get("data")) + if encoded == "" { + logger.Log().Warn("[proxy] Data missing") + httpError(w, "Error: Data missing") + return + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + logger.Log().Warnf("[proxy] Data parameter corrupted: %s", err.Error()) + httpError(w, "Error: invalid request") + return + } + + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + logger.Log().Warnf("[proxy] Invalid data parameter: %s", string(decoded)) + httpError(w, "Error: invalid request") + return + } + + id := parts[0] + uri := parts[1] + + links, err := getAssets(id) + if err != nil { + httpError(w, "Error: invalid request") + return + } + + if !tools.InArray(uri, links) { + logger.Log().Warnf("[proxy] URL %s not found in message %s", uri, id) + httpError(w, "Error: invalid request") return } if !linkRe.MatchString(uri) { - logger.Log().Warnf("[proxy] invalid URL %s", uri) - httpError(w, "Error: invalid URL") + logger.Log().Warnf("[proxy] invalid request %s", uri) + httpError(w, "Error: invalid request") return } @@ -46,7 +116,7 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) { req, err := http.NewRequest("GET", uri, nil) if err != nil { logger.Log().Warnf("[proxy] %s", err.Error()) - httpError(w, err.Error()) + httpError(w, "Error: invalid request") return } @@ -56,23 +126,34 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) { resp, err := client.Do(req) if err != nil { logger.Log().Warnf("[proxy] %s", err.Error()) - httpError(w, err.Error()) + httpError(w, "Error: invalid request") return } defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + logger.Log().Warnf("[proxy] received status code %d for %s", resp.StatusCode, uri) + httpError(w, "Error: invalid request") + return + } + + ct := strings.ToLower(resp.Header.Get("content-type")) + if !supportedProxyContentType(ct) { + logger.Log().Warnf("[proxy] blocking unsupported content-type %s for %s", ct, uri) + httpError(w, "Error: invalid request") + return + } + body, err := io.ReadAll(resp.Body) if err != nil { logger.Log().Warnf("[proxy] %s", err.Error()) - httpError(w, err.Error()) + httpError(w, "Error: invalid request") return } // relay common headers - if resp.Header.Get("content-type") != "" { - w.Header().Set("content-type", resp.Header.Get("content-type")) - } + w.Header().Set("content-type", ct) if resp.Header.Get("last-modified") != "" { w.Header().Set("last-modified", resp.Header.Get("last-modified")) } @@ -83,7 +164,7 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("cache-control", resp.Header.Get("cache-control")) } - // replace url() values with proxy address, eg: fonts & images + // replace CSS url() values with proxy address, eg: fonts & images if strings.HasPrefix(resp.Header.Get("content-type"), "text/css") { var re = regexp.MustCompile(`(?mi)(url\((\'|\")?([^\)\'\"]+)(\'|\")?\))`) body = re.ReplaceAllFunc(body, func(s []byte) []byte { @@ -100,7 +181,20 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) { return []byte(parts[3]) } - return []byte("url(" + parts[2] + config.Webroot + "proxy?url=" + url.QueryEscape(address) + parts[4] + ")") + // store asset address against message ID + if result, ok := assets[id]; ok { + if !tools.InArray(address, result.Assets) { + assetsMutex.Lock() + result.Assets = append(result.Assets, address) + assets[id] = result + assetsMutex.Unlock() + } + } + + // encode with base64 to handle any special characters and group message ID with URL + encoded := base64.StdEncoding.EncodeToString([]byte(id + ":" + address)) + + return []byte("url(" + parts[2] + config.Webroot + "proxy?data=" + encoded + parts[4] + ")") }) } @@ -114,7 +208,82 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) { } } -// AbsoluteURL will return a full URL regardless whether it is relative or absolute +// GetAssets retrieves and parses the message to return linked assets. +// Linked CSS files are appended to the assets list via the ProxyHandler when proxying CSS files. +func getAssets(id string) ([]string, error) { + assetsMutex.Lock() + defer assetsMutex.Unlock() + + result, ok := assets[id] + if ok { + // return cached assets + return result.Assets, nil + } + + msg, err := storage.GetMessage(id) + if err != nil { + return nil, err + } + + links := []string{} + + reader := strings.NewReader(msg.HTML) + + // load the HTML document + doc, err := goquery.NewDocumentFromReader(reader) + if err != nil { + return nil, err + } + + // css & font links + doc.Find("link").Each(func(_ int, s *goquery.Selection) { + if href, exists := s.Attr("href"); exists { + if linkRe.MatchString(href) && !tools.InArray(href, links) { + links = append(links, href) + } + } + }) + + // images + doc.Find("img").Each(func(_ int, s *goquery.Selection) { + if src, exists := s.Attr("src"); exists { + if linkRe.MatchString(src) && !tools.InArray(src, links) { + links = append(links, src) + } + } + }) + + // background="<>" links + doc.Find("[background]").Each(func(_ int, s *goquery.Selection) { + if bg, exists := s.Attr("background"); exists { + if linkRe.MatchString(bg) && !tools.InArray(bg, links) { + links = append(links, bg) + } + } + }) + + // url(<>) links in style blocks + matches := urlRe.FindAllStringSubmatch(msg.HTML, -1) + for _, match := range matches { + if len(match) >= 3 { + link := match[2] + if linkRe.MatchString(link) && !tools.InArray(link, links) { + links = append(links, link) + } + } + } + + r := MessageAssets{} + r.ID = id + r.Created = time.Now() + r.Assets = links + assets[id] = r + + return links, nil +} + +// AbsoluteURL will return a full URL regardless whether it is relative or absolute. +// This is used to replace relative CSS url(...) links when proxying. func absoluteURL(link, baseURL string) (string, error) { // scheme relative links, eg <script src="//example.com/script.js"> if len(link) > 1 && link[0:2] == "//" { @@ -156,3 +325,35 @@ func httpError(w http.ResponseWriter, msg string) { w.Header().Set("Content-Type", "text/plain") _, _ = fmt.Fprint(w, msg) } + +// SupportedProxyContentType checks if the content-type is supported for proxying. +// This is limited to fonts, images and css only. +func supportedProxyContentType(ct string) bool { + ct = strings.ToLower(ct) + + types := []string{ + "font/otf", + "font/ttf", + "font/woff", + "font/woff2", + "image/apng", + "image/avif", + "image/bmp", + "image/gif", + "image/jpeg", + "image/jpg", + "image/png", + "image/tiff", + "image/svg+xml", + "image/webp", + "text/css", + } + + for _, t := range types { + if strings.HasPrefix(ct, t) { + return true + } + } + + return false +}
server/ui-src/components/message/MessageScreenshot.vue+26 −15 modified@@ -27,29 +27,24 @@ export default { methods: { initScreenshot() { this.loading = 1; + const baseUrl = `${location.protocol}//${location.host}/`; + // absolute proxy URL + const proxy = new URL(this.resolve("/proxy"), baseUrl).href; + const urlRegex = /(url\(('|")?(https?:\/\/[^)'"]+)('|")?\))/gim; + // remove base tag, if set let h = this.message.HTML.replace(/<base .*>/im, ""); - const proxy = this.resolve("/proxy"); // Outlook hacks - else screenshot returns blank image h = h.replace(/<html [^>]+>/gim, "<html>"); // remove html attributes h = h.replace(/<o:p><\/o:p>/gm, ""); // remove empty `<o:p></o:p>` tags h = h.replace(/<o:/gm, "<"); // replace `<o:p>` tags with `<p>` h = h.replace(/<\/o:/gm, "</"); // replace `</o:p>` tags with `</p>` - // update any inline `url(...)` absolute links - const urlRegex = /(url\(('|")?(https?:\/\/[^)'"]+)('|")?\))/gim; - h = h.replaceAll(urlRegex, (match, p1, p2, p3) => { - if (typeof p2 === "string") { - return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`; - } - return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`; - }); - // create temporary document to manipulate const doc = document.implementation.createHTMLDocument(); doc.open(); - doc.write(h); + doc.writeln(h); doc.close(); // remove any <script> tags @@ -58,17 +53,30 @@ export default { i.parentNode.removeChild(i); } + // replace any url(...) links in <style> blocks + const styles = doc.getElementsByTagName("style"); + for (const i of styles) { + i.innerHTML = i.innerHTML.replaceAll(urlRegex, (match, p1, p2, p3) => { + if (typeof p2 === "string") { + // quoted URL + return ( + `url(${p2}${proxy}?data=` + btoa(this.message.ID + ":" + this.decodeEntities(p3)) + `${p2})` + ); + } + return `url(${proxy}?data=` + btoa(this.message.ID + ":" + this.decodeEntities(p3)) + `)`; + }); + } + // replace stylesheet links with proxy links const stylesheets = doc.getElementsByTagName("link"); for (const i of stylesheets) { const src = i.getAttribute("href"); - if ( src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0 ) { - i.setAttribute("href", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src))); + i.setAttribute("href", `${proxy}?data=` + btoa(this.message.ID + ":" + this.decodeEntities(src))); } } @@ -81,7 +89,7 @@ export default { src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0 ) { - i.setAttribute("src", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src))); + i.setAttribute("src", `${proxy}?data=` + btoa(this.message.ID + ":" + this.decodeEntities(src))); } } @@ -96,7 +104,10 @@ export default { src.indexOf(window.location.origin + window.location.pathname) !== 0 ) { // replace with proxy link - i.setAttribute("background", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src))); + i.setAttribute( + "background", + `${proxy}?data=` + btoa(this.message.ID + ":" + this.decodeEntities(src)), + ); } }
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-8v65-47jx-7mfrghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-21859ghsaADVISORY
- github.com/axllent/mailpit/commit/3b9b470c093b3d20b7d751722c1c24f3eed2e19dghsax_refsource_MISCWEB
- github.com/axllent/mailpit/security/advisories/GHSA-8v65-47jx-7mfrghsax_refsource_CONFIRMWEB
- pkg.go.dev/vuln/GO-2026-4284ghsaWEB
News mentions
0No linked articles in our index yet.