Media: Harden Vision URL fetching against SSRF and DoS

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2026-03-03 17:54:03 +01:00
parent b0fda99e2c
commit 80338bdebc
5 changed files with 95 additions and 17 deletions

View file

@ -110,7 +110,7 @@ func (m *Model) Url(imgUrl string, confidenceThreshold int) (result Labels, err
var data []byte
if data, err = media.ReadUrl(imgUrl, scheme.HttpsData); err != nil {
if data, err = media.ReadUrlImage(imgUrl, scheme.HttpsData); err != nil {
return nil, err
}

View file

@ -62,7 +62,7 @@ func (m *Model) Url(imgUrl string) (result Result, err error) {
var img []byte
if img, err = media.ReadUrl(imgUrl, scheme.HttpsData); err != nil {
if img, err = media.ReadUrlImage(imgUrl, scheme.HttpsData); err != nil {
return result, err
}

View file

@ -65,7 +65,7 @@ func PostVisionFace(router *gin.RouterGroup) {
results := make([]face.Embeddings, len(request.Images))
for i := range request.Images {
if data, err := media.ReadUrl(request.Images[i], scheme.HttpsData); err != nil {
if data, err := media.ReadUrlImage(request.Images[i], scheme.HttpsData); err != nil {
results[i] = face.Embeddings{}
log.Errorf("vision: %s (read face embedding from url)", err)
} else if result, faceErr := vision.GenerateFaceEmbeddings(data); faceErr != nil {

View file

@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"slices"
@ -14,9 +13,12 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/http/safe"
"github.com/photoprism/photoprism/pkg/http/scheme"
)
const imageAcceptHeader = "image/jpeg, image/png, image/webp, image/avif, image/heic, image/heif, */*;q=0.1"
// DataUrl generates a data URL of the binary data from the specified io.Reader.
func DataUrl(r io.Reader) string {
// Read binary data.
@ -61,6 +63,19 @@ func DataBase64(r io.Reader) string {
// fetches its data from a remote http or https URL,
// or decodes a base64 data URL as created by DataUrl.
func ReadUrl(fileUrl string, schemes []string) (data []byte, err error) {
return ReadUrlWithOptions(fileUrl, schemes, nil)
}
// ReadUrlImage reads binary image data with strict remote URL safety defaults.
func ReadUrlImage(fileUrl string, schemes []string) (data []byte, err error) {
return ReadUrlWithOptions(fileUrl, schemes, &safe.Options{
AllowPrivate: false,
Accept: imageAcceptHeader,
})
}
// ReadUrlWithOptions reads binary data while applying optional safe HTTP options for remote URLs.
func ReadUrlWithOptions(fileUrl string, schemes []string, opt *safe.Options) (data []byte, err error) {
if fileUrl == "" {
return data, errors.New("missing url")
}
@ -81,20 +96,12 @@ func ReadUrl(fileUrl string, schemes []string) (data []byte, err error) {
// Fetch the file data from the specified URL, depending on its scheme.
switch u.Scheme {
case scheme.Https, scheme.Http, scheme.Unix, scheme.HttpUnix:
resp, httpErr := http.Get(fileUrl) //nolint:gosec // URL already validated by caller; https/http only
if httpErr != nil {
return data, fmt.Errorf("invalid %s url (%s)", u.Scheme, httpErr)
}
defer func() {
err = errors.Join(err, resp.Body.Close())
}()
if data, err = io.ReadAll(resp.Body); err != nil {
return data, err
case scheme.Https, scheme.Http:
if data, err = readRemoteUrl(fileUrl, opt); err != nil {
return data, fmt.Errorf("invalid %s url (%w)", u.Scheme, err)
}
case scheme.Unix, scheme.HttpUnix:
return data, fmt.Errorf("unsupported url scheme %s", clean.Log(u.Scheme))
case scheme.Data:
if _, binaryData, found := strings.Cut(u.Opaque, ";base64,"); !found || len(binaryData) == 0 {
return data, fmt.Errorf("invalid %s url", u.Scheme)
@ -118,3 +125,43 @@ func ReadUrl(fileUrl string, schemes []string) (data []byte, err error) {
return data, err
}
// readRemoteUrl downloads a remote URL with safe defaults and returns the resulting bytes.
func readRemoteUrl(rawURL string, opt *safe.Options) (data []byte, err error) {
tmpFile, err := os.CreateTemp("", "photoprism-read-url-*")
if err != nil {
return data, err
}
tmpName := tmpFile.Name()
if closeErr := tmpFile.Close(); closeErr != nil {
return data, closeErr
}
defer func() {
_ = os.Remove(tmpName)
}()
options := &safe.Options{
AllowPrivate: true,
Accept: "*/*",
}
if opt != nil {
options = &safe.Options{
Timeout: opt.Timeout,
MaxSizeBytes: opt.MaxSizeBytes,
AllowPrivate: opt.AllowPrivate,
Accept: opt.Accept,
}
}
if err = safe.Download(tmpName, rawURL, options); err != nil {
return data, err
}
data, err = os.ReadFile(tmpName) //nolint:gosec // tmpName is created by os.CreateTemp
return data, err
}

View file

@ -11,6 +11,8 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/http/safe"
)
const gopher = `iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDsJkoEpMrY/vO2BIYQ6LLvm0ThY3MzDzzeSJeeWNyTkgnIE5ePKsvKlcg/0T9QMzXalwXMlj54z4c0rh/mzEfr+FgWEz2w6uk8dkzFAgcARAgNp1ZYef8bH2AgvuStbc2/i6CiWGj98y2tw2l4FAXKkQBIf+exyRnteY83LfEwDQAYCoK+P6bxkZm/0966LxcAAILHB56kgD95PPxltuYcMtFTWw/FKkY/6Opf3GGd9ZF+Qp6mzJxzuRSractOmJrH1u8XTvWFHINNkLQLMR+XHXvfPPHw967raE1xxwtA36IMRfkAAG29/7mLuQcb2WOnsJReZGfpiHsSBX81cvMKywYZHhX5hFPtOqPGWZCXnhWGAu6lX91ElKXSalcLXu3UaOXVay57ZSe5f6Gpx7J2MXAsi7EqSp09b/MirKSyJfnfEEgeDjl8FgDAfvewP03zZ+AJ0m9aFRM8eEHBDRKjfcreDXnZdQuAxXpT2NRJ7xl3UkLBhuVGU16gZiGOgZmrSbRdqkILuL/yYoSXHHkl9KXgqNu3PB8oRg0geC5vFmLjad6mUyTKLmF3OtraWDIfACyXqmephaDABawfpi6tqqBZytfQMqOz6S09iWXhktrRaB8Xz4Yi/8gyABDm5NVe6qq/3VzPrcjELWrebVuyY2T7ar4zQyybUCtsQ5Es1FGaZVrRVQwAgHGW2ZCRZshI5bGQi7HesyE972pOSeMM0dSktlzxRdrlqb3Osa6CCS8IJoQQQgBAbTAa5l5epO34rJszibJI8rxLfGzcp1dRosutGeb2VDNgqYrwTiPNsLxXiPi3dz7LiS1WBRBDBOnqEjyy3aQb+/bLiJzz9dIkscVBBLxMfSEac7kO4Fpkngi0ruNBeSOal+u8jgOuqPz12nryMLCniEjtOOOmpt+KEIqsEdocJjYXwrh9OZqWJQyPCTo67LNS/TdxLAv6R5ZNK9npEjbYdT33gRo4o5oTqR34R+OmaSzDBWsAIPhuRcgyoteNi9gF0KzNYWVItPf2TLoXEg+7isNC7uJkgo1iQWOfRSP9NR11RtbZZ3OMG/VhL6jvx+J1m87+RCfJChAtEBQkSBX2PnSiihc/Twh3j0h7qdYQAoRVsRGmq7HU2QRbaxVGa1D6nIOqaIWRjyRZpHMQKWKpZM5feA+lzC4ZFultV8S6T0mzQGhQohi5I8iw+CsqBSxhFMuwyLgSwbghGb0AiIKkSDmGZVmJSiKihsiyOAUs70UkywooYP0bii9GdH4sfr1UNysd3fUyLLMQN+rsmo3grHl9VNJHbbwxoa47Vw5gupIqrZcjPh9R4Nye3nRDk199V+aetmvVtDRE8/+cbgAAgMIWGb3UA0MGLE9SCbWX670TDy1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2SnssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg==`
@ -61,6 +63,35 @@ func TestReadUrl(t *testing.T) {
}
assert.Equal(t, []byte("hello"), data)
})
t.Run("HttpServerPrivateBlocked", func(t *testing.T) {
ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("hello"))
})
_, err := ReadUrlImage(ts.URL, []string{"http", "https"})
assert.ErrorIs(t, err, safe.ErrPrivateIP)
})
t.Run("HttpServerMaxSize", func(t *testing.T) {
ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("hello"))
})
_, err := ReadUrlWithOptions(ts.URL, []string{"http", "https"}, &safe.Options{
AllowPrivate: true,
MaxSizeBytes: 4,
})
assert.ErrorIs(t, err, safe.ErrSizeExceeded)
})
t.Run("HttpServerStatus", func(t *testing.T) {
ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
})
_, err := ReadUrlWithOptions(ts.URL, []string{"http", "https"}, &safe.Options{
AllowPrivate: true,
})
assert.Error(t, err)
})
t.Run("InvalidEmpty", func(t *testing.T) {
_, err := ReadUrl("", []string{"https"})
assert.Error(t, err)