Files
remark42/backend/app/rest/proxy/image.go
T
Umputun aca0cff399 fix: IPv6 address truncation and image proxy SSRF vulnerabilities
Replace strings.Split(RemoteAddr, ":") with net.SplitHostPort for correct
IPv6 address extraction in vote deduplication and comment IP tracking.

Harden image proxy: add SSRF-safe transport blocking private/reserved IPs
at connection time with DNS rebinding protection, sanitize error messages
to prevent information leakage, add response size limit via io.LimitReader.

Fix shadowed error variables in BlockedUsers, SetTitle, and Delete methods.
Exclude gosec taint analysis false positives at linter config level.
2026-02-28 04:13:07 -06:00

270 lines
8.1 KiB
Go

package proxy
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
log "github.com/go-pkgz/lgr"
"github.com/go-pkgz/repeater/v2"
"github.com/umputun/remark42/backend/app/rest"
"github.com/umputun/remark42/backend/app/store/image"
)
// Image extracts image src from comment's html and provides proxy for them
// this is needed to keep remark42 running behind of HTTPS serve all images via https
type Image struct {
RemarkURL string
RoutePath string
HTTP2HTTPS bool
CacheExternal bool
Timeout time.Duration
ImageService *image.Service
Transport http.RoundTripper // if nil, uses SSRF-safe transport blocking private IPs
}
// Convert img src links to proxied links depends on enabled options
func (p Image) Convert(commentHTML string) string {
if p.CacheExternal {
imgs, err := p.extract(commentHTML, func(img string) bool { return !strings.HasPrefix(img, p.RemarkURL) })
if err != nil {
return commentHTML
}
commentHTML = p.replace(commentHTML, imgs)
}
if p.HTTP2HTTPS && !strings.HasPrefix(p.RemarkURL, "http://") {
imgs, err := p.extract(commentHTML, func(img string) bool { return strings.HasPrefix(img, "http://") })
if err != nil {
return commentHTML
}
commentHTML = p.replace(commentHTML, imgs)
}
return commentHTML
}
// extract gets all images matching predicate and return list of src
func (p Image) extract(commentHTML string, imgSrcPred func(string) bool) ([]string, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(commentHTML))
if err != nil {
return nil, fmt.Errorf("can't create document: %w", err)
}
result := []string{}
doc.Find("img").Each(func(_ int, s *goquery.Selection) {
if im, ok := s.Attr("src"); ok {
if imgSrcPred(im) {
result = append(result, im)
}
}
})
return result, nil
}
// replace img links in commentHTML with route to proxy, base64 encoded original link
func (p Image) replace(commentHTML string, imgs []string) string {
for _, img := range imgs {
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(img))
resImgURL := p.RemarkURL + p.RoutePath + "?src=" + encodedImgURL
commentHTML = strings.ReplaceAll(commentHTML, img, resImgURL)
}
return commentHTML
}
// Handler returns http handler respond to proxied request
func (p Image) Handler(w http.ResponseWriter, r *http.Request) {
src, err := base64.URLEncoding.DecodeString(r.URL.Query().Get("src"))
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't decode image url", rest.ErrDecode)
return
}
imgURL := string(src)
var img []byte
imgID, err := image.CachedImgID(imgURL)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("invalid image url"), "can't parse image url", rest.ErrAssetNotFound)
return
}
// try to load from cache for case it was saved when CacheExternal was enabled
img, _ = p.ImageService.Load(imgID)
if img == nil {
img, err = p.downloadImage(context.Background(), imgURL)
if err != nil {
log.Printf("[WARN] failed to download image: %v", err)
if strings.Contains(err.Error(), "invalid content type") {
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("invalid content type"), "invalid content type", rest.ErrImgNotFound)
return
}
rest.SendErrorJSON(w, r, http.StatusNotFound, fmt.Errorf("failed to fetch"), "can't get image", rest.ErrAssetNotFound)
return
}
if p.CacheExternal {
p.cacheImage(bytes.NewReader(img), imgID)
}
}
// enforce client-side caching
etag := `"` + r.URL.Query().Get("src") + `"`
w.Header().Set("Etag", etag)
w.Header().Set("Cache-Control", "max-age=2592000") // 30 days
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, etag) {
w.WriteHeader(http.StatusNotModified)
return
}
}
w.Header().Add("Content-Type", p.ImageService.ImgContentType(img))
_, err = io.Copy(w, bytes.NewReader(img))
if err != nil {
log.Printf("[WARN] can't copy image stream, %s", err)
}
}
// cache image from provided Reader using given ID
func (p Image) cacheImage(r io.Reader, imgID string) {
err := p.ImageService.SaveWithID(imgID, r)
if err != nil {
log.Printf("[WARN] unable to save image to the storage: %+v", err)
}
}
// download an image.
func (p Image) downloadImage(ctx context.Context, imgURL string) ([]byte, error) {
log.Printf("[DEBUG] downloading image %s", imgURL)
timeout := 60 * time.Second // default
if p.Timeout > 0 {
timeout = p.Timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
transport := p.Transport
if transport == nil {
transport = ssrfSafeTransport()
}
client := http.Client{
Timeout: 30 * time.Second,
Transport: transport,
}
defer client.CloseIdleConnections()
var resp *http.Response
err := repeater.NewFixed(5, time.Second).Do(ctx, func() error {
var e error
req, e := http.NewRequest("GET", imgURL, http.NoBody)
if e != nil {
return fmt.Errorf("failed to make request for %s: %w", imgURL, e)
}
resp, e = client.Do(req.WithContext(ctx)) //nolint:bodyclose,gosec // body closed in defer; SSRF mitigated by ssrfSafeTransport
return e
})
if err != nil {
return nil, fmt.Errorf("can't download image %s: %w", imgURL, err)
}
defer resp.Body.Close() //nolint gosec // we don't care about response body
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("got unsuccessful response status %d while fetching %s", resp.StatusCode, imgURL)
}
contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
return nil, fmt.Errorf("invalid content type %s", contentType)
}
maxSize := 5 * 1024 * 1024 // 5MB default
if p.ImageService != nil && p.ImageService.MaxSize > 0 {
maxSize = p.ImageService.MaxSize
}
lr := io.LimitReader(resp.Body, int64(maxSize)+1)
imgData, err := io.ReadAll(lr)
if err != nil {
return nil, fmt.Errorf("unable to read image body: %w", err)
}
if len(imgData) > maxSize {
return nil, fmt.Errorf("image is too large")
}
return imgData, nil
}
// ssrfSafeTransport returns an http.Transport with a dialer that blocks connections to private IP addresses.
// it resolves the host, validates all IPs, then dials using the resolved IP to prevent DNS rebinding attacks.
// tries each resolved IP in order to handle dual-stack hosts where the first IP may be unreachable.
func ssrfSafeTransport() *http.Transport {
dialer := &net.Dialer{Timeout: 30 * time.Second}
return &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("invalid address %s: %w", addr, err)
}
// resolve the host to IP addresses
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("can't resolve host %s: %w", host, err)
}
if len(ips) == 0 {
return nil, fmt.Errorf("no IP addresses resolved for host %s", host)
}
for _, ip := range ips {
if isPrivateIP(ip.IP) {
return nil, fmt.Errorf("access to private address is not allowed")
}
}
// try each resolved IP to handle dual-stack hosts where some IPs may be unreachable
var lastErr error
for _, ip := range ips {
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, fmt.Errorf("can't connect to %s: %w", host, lastErr)
},
}
}
// privateCIDRs holds pre-parsed private/reserved CIDR blocks for SSRF protection.
var privateCIDRs = func() []*net.IPNet {
cidrs := []string{
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
"::1/128", "fc00::/7", "fe80::/10",
}
blocks := make([]*net.IPNet, 0, len(cidrs))
for _, cidr := range cidrs {
_, block, _ := net.ParseCIDR(cidr)
blocks = append(blocks, block)
}
return blocks
}()
// isPrivateIP checks if the given IP belongs to a private/reserved range.
func isPrivateIP(ip net.IP) bool {
if ip.IsUnspecified() {
return true
}
for _, block := range privateCIDRs {
if block.Contains(ip) {
return true
}
}
return false
}