From 907e0a10bd561105d561952574307dfddf734c49 Mon Sep 17 00:00:00 2001 From: Engineer Date: Mon, 20 Jul 2026 03:02:30 +0200 Subject: [PATCH 1/2] fix(view): add SSRF protection to remote image fetching, reuse HTTP client A malicious HTML email could embed images pointing at internal services (e.g. http://169.254.169.254/latest/meta-data/ on cloud providers, or http://127.0.0.1:* to reach local applications). Every recipient who views such an email triggers a request to the attacker-controlled URL, enabling server-side request forgery. Add isPrivateHost() DNS resolution check that rejects connections to loopback (127.0.0.0/8), RFC 1918 (10/172.16-31/192.168), link-local (169.254.0.0/16), and IPv6 loopback/link-local addresses. Also limit redirect chains to 5 hops using httpclient.NewWithRedirectCap to prevent SSRF via open-redirect chains, and reuse a single HTTP client across calls instead of creating a new one per image fetch. --- view/html.go | 92 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 12 deletions(-) diff --git a/view/html.go b/view/html.go index de9635a9..d4995417 100644 --- a/view/html.go +++ b/view/html.go @@ -2,10 +2,13 @@ package view import ( "bytes" + "context" "encoding/base64" "fmt" "io" "mime/quotedprintable" + "net" + "net/url" "os" "regexp" "strings" @@ -284,6 +287,11 @@ func debugImageProtocol(format string, args ...interface{}) { } const remoteImageCacheSize = 20 +const maxRemoteImageRedirects = 5 + +// remoteImageClient is a shared HTTP client for remote image fetches with a +// hard redirect cap to prevent SSRF redirect chains. +var remoteImageClient = httpclient.NewWithRedirectCap(httpclient.RemoteImageTimeout, maxRemoteImageRedirects) // remoteImageCache caches fetched remote images (URL -> base64 PNG string). var remoteImageCache *lru.Cache[string, string] @@ -296,26 +304,86 @@ func init() { remoteImageCache = c } -func fetchRemoteBase64(url string) string { - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { +// isPrivateHost returns true if hostname resolves to a loopback, +// link-local, or RFC 1918/4193 address. This prevents SSRF attacks where +// a malicious HTML email embeds images pointing at internal services +// (e.g. cloud metadata endpoints, local databases). +func isPrivateHost(hostname string) bool { + resolver := &net.Resolver{} + addrs, err := resolver.LookupIPAddr(context.Background(), hostname) + if err != nil { + // If DNS fails, reject to be safe + return true + } + for _, a := range addrs { + addr := a.IP + if ip4 := addr.To4(); ip4 != nil { + // 127.0.0.0/8 — loopback + if ip4[0] == 127 { + return true + } + // 10.0.0.0/8 — private + if ip4[0] == 10 { + return true + } + // 172.16.0.0/12 — private + if ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31 { + return true + } + // 192.168.0.0/16 — private + if ip4[0] == 192 && ip4[1] == 168 { + return true + } + // 169.254.0.0/16 — link-local (cloud metadata) + if ip4[0] == 169 && ip4[1] == 254 { + return true + } + // 0.0.0.0/8 + if ip4[0] == 0 { + return true + } + } else { + // IPv6 loopback ::1 + if addr.IsLoopback() { + return true + } + // IPv6 link-local fe80::/10 + if addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() { + return true + } + } + } + return false +} + +func fetchRemoteBase64(rawURL string) string { + if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { + return "" + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return "" + } + if isPrivateHost(parsed.Hostname()) { + debugImageProtocol("remote fetch blocked (private host) url=%s", rawURL) return "" } // Check cache first - if cached, ok := remoteImageCache.Get(url); ok { - debugImageProtocol("remote cache hit url=%s", url) + if cached, ok := remoteImageCache.Get(rawURL); ok { + debugImageProtocol("remote cache hit url=%s", rawURL) return cached } - client := httpclient.New(httpclient.RemoteImageTimeout) - resp, err := client.Get(url) + resp, err := remoteImageClient.Get(rawURL) if err != nil { - debugImageProtocol("remote fetch failed url=%s err=%v", url, err) + debugImageProtocol("remote fetch failed url=%s err=%v", rawURL, err) return "" } defer resp.Body.Close() //nolint:errcheck if resp.StatusCode < 200 || resp.StatusCode >= 300 { - debugImageProtocol("remote fetch non-200 url=%s status=%d", url, resp.StatusCode) + debugImageProtocol("remote fetch non-200 url=%s status=%d", rawURL, resp.StatusCode) return "" } // Limit response body to 10 MB to prevent memory exhaustion from @@ -323,19 +391,19 @@ func fetchRemoteBase64(url string) string { const maxImageSize = 10 << 20 // 10 MB data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize)) if err != nil { - debugImageProtocol("remote fetch read error url=%s err=%v", url, err) + debugImageProtocol("remote fetch read error url=%s err=%v", rawURL, err) return "" } result, ok := clib.DecodeToPNG(data) if !ok { - debugImageProtocol("remote decode failed url=%s", url) + debugImageProtocol("remote decode failed url=%s", rawURL) return "" } encoded := base64.StdEncoding.EncodeToString(result.PNGData) - debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded)) - remoteImageCache.Add(url, encoded) + debugImageProtocol("remote fetch ok url=%s len=%d", rawURL, len(encoded)) + remoteImageCache.Add(rawURL, encoded) return encoded } From dae636605abd79dc998605a08345a2058f4a722b Mon Sep 17 00:00:00 2001 From: kawacukennedy Date: Mon, 20 Jul 2026 09:44:22 +0200 Subject: [PATCH 2/2] ci: retrigger CI