The image proxy got an ssrfSafeTransport in commitaca0cff3that 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 ofaca0cff3, 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.
201 lines
6.1 KiB
Go
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
|
|
}
|