Files
remark42/backend/app/rest/proxy/image.go
T
Dmitry VerkhoturovandUmputun ff85bbc5ea fix(ssrf): apply ssrf-safe transport to TitleExtractor
The image proxy got an ssrfSafeTransport in commit aca0cff3 that resolves
DNS first, blocks any IP in private/reserved CIDRs, then dials by IP to
defeat DNS rebinding. The TitleExtractor used to construct comments'
PostTitle from Locator.URL — a user-supplied field — was missed by that
fix and kept using http.DefaultTransport. The hostname allowlist there
checks the parsed URL host but never the IP it resolves to, so a domain
suffix-matching an allowed host (or 127.0.0.1 itself when AllowedHosts
is empty) reaches the metadata service or any other internal endpoint.

The same gosec rule (G704) was excluded globally in .golangci.yml as part
of aca0cff3, so this gap was not caught by the linter either.

Extract the transport into a new safehttp package so it lives in one
place and can be reused, then pass safehttp.Transport() into the
TitleExtractor's http.Client at construction (cmd/server.go). The image
proxy switches to safehttp.Transport() too — same behaviour, no longer
duplicated.

Reproduction in title_test.go uses the production-style client to hit
an httptest.Server (always 127.0.0.1) and asserts the dialer refuses
even though "127.0.0.1" is in the allowed-domains list. A control case
shows the same setup without safehttp.Transport returns the page —
making the original vulnerability explicit.
2026-04-18 02:32:31 -05:00

201 lines
6.1 KiB
Go

package proxy
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"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/safehttp"
"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 = safehttp.Transport()
}
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) //nolint:gosec // SSRF mitigated by safehttp.Transport assigned above
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 safehttp.Transport
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
}