mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Media: Harden Vision URL fetching against SSRF and DoS
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
b0fda99e2c
commit
80338bdebc
5 changed files with 95 additions and 17 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue