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
}