Address review feedback on PR #2044. safehttp.Transport(): * Clone http.DefaultTransport instead of building a bare &http.Transport{} so Proxy, ForceAttemptHTTP2, MaxIdleConns, IdleConnTimeout, TLSHandshakeTimeout and ExpectContinueTimeout are inherited (the bare struct loses them all). Verified by new TestTransport_PreservesDefaultTransportSettings. * TestTransport_AllowsPublic: bound the dial of TEST-NET-3 with a 100ms context so the test does not depend on real-world routing of 203.0.113.0/24, and drop the dead dialer var. proxy/image.go: * Document Image.Transport contract: nil installs safehttp.Transport (SSRF-safe); caller-supplied transport is the caller's responsibility. * Replace the misleading "SSRF mitigated by safehttp.Transport" nolint comments with one that points at the documented contract above.
208 lines
6.4 KiB
Go
208 lines
6.4 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, if non-nil, is used as-is for outbound image fetches and is the
|
|
// caller's responsibility to make SSRF-safe. When nil, safehttp.Transport()
|
|
// is installed, which blocks dialing any private/reserved IP and resolves
|
|
// hostnames to defeat DNS rebinding.
|
|
Transport http.RoundTripper
|
|
}
|
|
|
|
// 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
|
|
// SSRF safety: client.Transport is safehttp.Transport() when p.Transport is nil
|
|
// (see Image.Transport contract above); when caller supplies a transport they
|
|
// own SSRF safety for that path.
|
|
req, e := http.NewRequest("GET", imgURL, http.NoBody) //nolint:gosec // see comment 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; transport contract above
|
|
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
|
|
}
|