VYPR
Moderate severityOSV Advisory· Published Jan 7, 2026· Updated Jan 8, 2026

Mailpit Proxy Endpoint is Vulnerable to Server-Side Request Forgery (SSRF)

CVE-2026-21859

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.

PackageAffected versionsPatched versions
github.com/axllent/mailpitGo
< 1.28.11.28.1

Affected products

1

Patches

1
3b9b470c093b

Security: 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)

https://github.com/axllent/mailpitRalph SlootenJan 6, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.