package proxy import ( "encoding/base64" "fmt" "io" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/umputun/remark42/backend/app/store/image" ) // gopher png for test, from https://golang.org/src/image/png/example_test.go const gopher = "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" func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) } func gopherPNGBytes() []byte { img, _ := io.ReadAll(gopherPNG()) return img } func TestImage_Extract(t *testing.T) { tbl := []struct { inp string res []string }{ { `

blah test

`, []string{"http://radio-t.com/img.png"}, }, { `

blah test

`, []string{}, }, { ``, []string{"http://radio-t.com/img2.png"}, }, { `
xyz
`, []string{"http://radio-t.com/img3.png", "http://images.pexels.com/67636/img4.jpeg"}, }, { `
xyz
`, []string{"http://images.pexels.com/67636/img4.jpeg"}, }, { `abcd blah

xxx

`, []string{}, }, } img := Image{HTTP2HTTPS: true} for i, tt := range tbl { t.Run(strconv.Itoa(i), func(t *testing.T) { res, err := img.extract(tt.inp, func(src string) bool { return strings.HasPrefix(src, "http://") }) assert.NoError(t, err) assert.Equal(t, tt.res, res) }) } } func TestImage_Replace(t *testing.T) { img := Image{HTTP2HTTPS: true, RoutePath: "/img"} r := img.replace(` xyz `, []string{"http://radio-t.com/img3.png", "http://images.pexels.com/67636/img4.jpeg"}) assert.Equal(t, ` xyz `, r) } func TestImage_Routes(t *testing.T) { // no image supposed to be cached imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }} img := Image{ HTTP2HTTPS: true, RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", ImageService: image.NewService(&imageStore, image.ServiceParams{}), Transport: http.DefaultTransport, } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) defer ts.Close() httpSrv := imgHTTPTestsServer(t) defer httpSrv.Close() t.Run("valid image", func(t *testing.T) { encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png")) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) require.NoError(t, err) assert.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "1462", resp.Header["Content-Length"][0]) assert.Equal(t, "image/png", resp.Header["Content-Type"][0]) }) t.Run("no image", func(t *testing.T) { encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/no-such-image.png")) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) require.NoError(t, err) assert.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusNotFound, resp.StatusCode) }) t.Run("bad encoding", func(t *testing.T) { encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "bad encoding")) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) require.NoError(t, err) assert.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) assert.Equal(t, 2, len(imageStore.LoadCalls())) }) t.Run("non-image reference", func(t *testing.T) { encodedImgURL := base64.URLEncoding.EncodeToString([]byte("https://google.com")) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) require.NoError(t, err) assert.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) assert.Equal(t, 3, len(imageStore.LoadCalls())) }) } func TestImage_DisabledCachingAndHTTP2HTTPS(t *testing.T) { imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }} img := Image{ RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", ImageService: image.NewService(&imageStore, image.ServiceParams{}), Transport: http.DefaultTransport, } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) defer ts.Close() httpSrv := imgHTTPTestsServer(t) defer httpSrv.Close() encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png")) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) require.NoError(t, err) assert.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "1462", resp.Header["Content-Length"][0]) assert.Equal(t, "image/png", resp.Header["Content-Type"][0]) assert.Equal(t, 1, len(imageStore.LoadCalls())) } func TestImage_RoutesCachingImage(t *testing.T) { imageStore := image.StoreMock{ LoadFunc: func(string) ([]byte, error) { return nil, nil }, SaveFunc: func(string, []byte) error { return nil }, } img := Image{ CacheExternal: true, RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", ImageService: image.NewService(&imageStore, image.ServiceParams{MaxSize: 1500}), Transport: http.DefaultTransport, } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) defer ts.Close() httpSrv := imgHTTPTestsServer(t) defer httpSrv.Close() imgURL := httpSrv.URL + "/image/img1.png" encodedImgURL := base64.URLEncoding.EncodeToString([]byte(imgURL)) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) require.NoError(t, err) assert.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "1462", resp.Header["Content-Length"][0]) assert.Equal(t, "image/png", resp.Header["Content-Type"][0]) assert.Equal(t, 1, len(imageStore.LoadCalls())) assert.Equal(t, 1, len(imageStore.SaveCalls())) assert.Equal(t, "cached_images/4b84b15bff6ee5796152495a230e45e3d7e947d9-"+image.Sha1Str(imgURL), imageStore.SaveCalls()[0].ID) assert.Equal(t, gopherPNGBytes(), imageStore.SaveCalls()[0].Img) } func TestImage_RoutesUsingCachedImage(t *testing.T) { t.Run("cached image is served", func(t *testing.T) { imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return gopherPNGBytes(), nil }} img := Image{ CacheExternal: true, RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", ImageService: image.NewService(&imageStore, image.ServiceParams{}), } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) defer ts.Close() httpSrv := imgHTTPTestsServer(t) defer httpSrv.Close() encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png")) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) require.NoError(t, err) assert.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "image/png", resp.Header.Get("Content-Type")) assert.Equal(t, 1, len(imageStore.LoadCalls())) }) t.Run("non-image cached bytes are rejected (cache poisoning defense)", func(t *testing.T) { nonImage := fmt.Appendf(nil, "%256s", "X") imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nonImage, nil }} img := Image{ CacheExternal: true, RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", ImageService: image.NewService(&imageStore, image.ServiceParams{}), } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) defer ts.Close() httpSrv := imgHTTPTestsServer(t) defer httpSrv.Close() encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png")) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) require.NoError(t, err) body, _ := io.ReadAll(resp.Body) assert.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusUnsupportedMediaType, resp.StatusCode, "non-image bytes from cache must be rejected, not served as text/plain (XSS defense)") assert.False(t, strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html"), "reject response must not be text/html; got %q", resp.Header.Get("Content-Type")) assert.NotContains(t, string(body), "XXXXX", "non-image bytes must not be echoed back") }) } func TestImage_RoutesTimedOut(t *testing.T) { // no image supposed to be cached imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }} img := Image{ HTTP2HTTPS: true, RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", Timeout: 50 * time.Millisecond, ImageService: image.NewService(&imageStore, image.ServiceParams{}), Transport: http.DefaultTransport, } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) defer ts.Close() httpSrv := imgHTTPTestsServer(t) defer httpSrv.Close() encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img-slow.png")) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) require.NoError(t, err) assert.Equal(t, http.StatusNotFound, resp.StatusCode) b, err := io.ReadAll(resp.Body) assert.NoError(t, resp.Body.Close()) require.NoError(t, err) t.Log(string(b)) assert.Contains(t, string(b), "failed to fetch") assert.NotContains(t, string(b), "deadline exceeded", "should not leak transport details") assert.Equal(t, 1, len(imageStore.LoadCalls())) } func TestImage_ConvertProxyMode(t *testing.T) { img := Image{HTTP2HTTPS: true, RoutePath: "/img"} r := img.Convert(` xyz `) assert.Equal(t, ` xyz `, r) r = img.Convert(` xyz `) assert.Equal(t, ` xyz `, r) img = Image{HTTP2HTTPS: true, RoutePath: "/img", RemarkURL: "http://example.com"} r = img.Convert(` xyz`) assert.Equal(t, ` xyz`, r, "http:// remark url, no proxy") img = Image{HTTP2HTTPS: false, RoutePath: "/img"} r = img.Convert(` xyz`) assert.Equal(t, ` xyz`, r, "disabled, no proxy") } func TestImage_ConvertCachingMode(t *testing.T) { img := Image{CacheExternal: true, RoutePath: "/img", RemarkURL: "https://remark42.com"} r := img.Convert(` xyz `) assert.Equal(t, ` xyz `, r) r = img.Convert(` xyz `) assert.Equal(t, ` xyz `, r) r = img.Convert(``) assert.Equal(t, ``, r) img = Image{CacheExternal: false, RoutePath: "/img", RemarkURL: "https://remark42.com"} r = img.Convert(``) assert.Equal(t, ``, r) // both Caching and Proxy enabled img = Image{CacheExternal: true, HTTP2HTTPS: true, RoutePath: "/img", RemarkURL: "https://remark42.com"} r = img.Convert(` xyz `) assert.Equal(t, ` xyz `, r) } func TestImage_PrivateIPBlocking(t *testing.T) { imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }} img := Image{ HTTP2HTTPS: true, RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", Timeout: 100 * time.Millisecond, ImageService: image.NewService(&imageStore, image.ServiceParams{}), // no Transport override — uses SSRF-safe transport } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) defer ts.Close() tbl := []struct { name string url string }{ {"loopback", "http://127.0.0.1/image.png"}, {"rfc1918 10.x", "http://10.0.0.1/image.png"}, {"rfc1918 172.16.x", "http://172.16.0.1/image.png"}, {"rfc1918 192.168.x", "http://192.168.1.1/image.png"}, {"link-local", "http://169.254.1.1/image.png"}, {"ipv6 loopback", "http://[::1]/image.png"}, } for _, tt := range tbl { t.Run(tt.name, func(t *testing.T) { encodedImgURL := base64.URLEncoding.EncodeToString([]byte(tt.url)) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) require.NoError(t, err) b, err := io.ReadAll(resp.Body) assert.NoError(t, resp.Body.Close()) require.NoError(t, err) assert.Equal(t, http.StatusNotFound, resp.StatusCode) assert.NotContains(t, string(b), "private address", "should not leak private IP check details") assert.Contains(t, string(b), "failed to fetch") }) } } func TestImage_ErrorSanitization(t *testing.T) { // server that immediately closes connections to simulate transport errors httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { hj, ok := w.(http.Hijacker) if !ok { w.WriteHeader(http.StatusInternalServerError) return } conn, _, _ := hj.Hijack() conn.Close() // forcefully close to trigger transport error })) defer httpSrv.Close() imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }} img := Image{ RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", Timeout: 2 * time.Second, ImageService: image.NewService(&imageStore, image.ServiceParams{}), Transport: http.DefaultTransport, } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) defer ts.Close() encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image.png")) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) require.NoError(t, err) b, err := io.ReadAll(resp.Body) assert.NoError(t, resp.Body.Close()) require.NoError(t, err) assert.Equal(t, http.StatusNotFound, resp.StatusCode) assert.Contains(t, string(b), "failed to fetch") assert.NotContains(t, string(b), "EOF", "should not leak transport details") assert.NotContains(t, string(b), "connection", "should not leak transport details") } func TestImage_ResponseSizeLimit(t *testing.T) { // create a test server that returns a large image largeImg := make([]byte, 2000) for i := range largeImg { largeImg[i] = 0xFF } httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "image/png") _, _ = w.Write(largeImg) })) defer httpSrv.Close() imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }} img := Image{ RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", ImageService: image.NewService(&imageStore, image.ServiceParams{MaxSize: 1000}), Transport: http.DefaultTransport, } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) defer ts.Close() encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/big-image.png")) resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) require.NoError(t, err) b, err := io.ReadAll(resp.Body) assert.NoError(t, resp.Body.Close()) require.NoError(t, err) assert.Equal(t, http.StatusNotFound, resp.StatusCode) assert.Contains(t, string(b), "failed to fetch") } // TestImage_ContentTypeHandling covers both the rock-solid acceptance of legitimate // images and the rejection of content-type-spoofing payloads (the XSS vector where // upstream lies about Content-Type and the proxy serves attacker HTML back from the // remark42 origin). Every response — accept or reject — must carry the layered // defense headers (strict CSP, nosniff, Content-Disposition: inline). // // The defense must not depend on the upstream Content-Type header: each row controls // it independently of the body so the matrix exercises attackers who flip the upstream // header on the fly, and polyglot bodies where image magic bytes prefix HTML payloads. func TestImage_ContentTypeHandling(t *testing.T) { htmlBody := []byte("") // polyglot: real PNG magic + trailing HTML. Sniffs as image/png, must be served // as image/png so the browser renders as image (broken or otherwise) — never as HTML. polyglot := append(append([]byte{}, gopherPNGBytes()...), []byte("")...) tbl := []struct { name string upstreamCT string // Content-Type header the upstream sends body []byte accept bool // true: legitimate image, served back; false: attack, rejected wantCT string // exact Content-Type if accept payloadMarker string // attack substring that must NOT appear in the response body }{ // legitimate {name: "real png", upstreamCT: "image/png", body: gopherPNGBytes(), accept: true, wantCT: "image/png"}, // upstream lies — body is HTML, header varies. All must be rejected at body-sniff. {name: "html body claimed as image/png", upstreamCT: "image/png", body: htmlBody, payloadMarker: "") polyglot := append(append([]byte{}, gopherPNGBytes()...), []byte("")...) tbl := []struct { name string cached []byte accept bool wantCT string payloadMarker string }{ {name: "html in cache claimed as image/png", cached: htmlBody, payloadMarker: "") upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/png") // always lie consistently switch r.URL.Path { case "/png": _, _ = w.Write(gopherPNGBytes()) case "/html": _, _ = w.Write(htmlBody) } })) defer upstream.Close() imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }} img := Image{ RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", ImageService: image.NewService(&imageStore, image.ServiceParams{}), Transport: http.DefaultTransport, } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) defer ts.Close() // alternate calls: PNG, HTML, PNG, HTML — each must be judged on its own bytes. type step struct { path string wantStatus int wantCT string // prefix match } steps := []step{ {path: "/png", wantStatus: http.StatusOK, wantCT: "image/png"}, {path: "/html", wantStatus: http.StatusUnsupportedMediaType, wantCT: "application/json"}, {path: "/png", wantStatus: http.StatusOK, wantCT: "image/png"}, {path: "/html", wantStatus: http.StatusUnsupportedMediaType, wantCT: "application/json"}, } for i, s := range steps { t.Run(fmt.Sprintf("step_%d_%s", i, s.path), func(t *testing.T) { encodedURL := base64.URLEncoding.EncodeToString([]byte(upstream.URL + s.path)) resp, err := http.Get(ts.URL + "/?src=" + encodedURL) require.NoError(t, err) body, _ := io.ReadAll(resp.Body) require.NoError(t, resp.Body.Close()) assert.Equal(t, s.wantStatus, resp.StatusCode) assert.True(t, strings.HasPrefix(resp.Header.Get("Content-Type"), s.wantCT), "expected Content-Type prefix %q, got %q", s.wantCT, resp.Header.Get("Content-Type")) assert.False(t, strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html"), "must never serve text/html under any flip") assert.NotContains(t, string(body), "