Files
remark42/backend/app/rest/proxy/image_test.go
T
Dmitry VerkhoturovandGitHub 0e20861419 fix(security): reject non-image content-types in image proxy and /picture/ to prevent stored XSS (#2067)
* fix(security): reject non-image content-types in image proxy and /picture/ to prevent stored XSS

The /api/v1/img proxy and /api/v1/picture/{user}/{id} endpoints emitted
http.DetectContentType on the served bytes as the response Content-Type. A
controlled upstream serving Content-Type: image/png with an HTML body passed
the upstream check (only the response header was inspected, not the body),
and the body bytes then sniffed back to text/html — so the proxy served the
attacker's HTML from the remark42 origin. Browsers honoured the declared
text/html and executed the response as a document with access to cookies and
CSRF tokens. Affected from v1.6.0 (April 2020) through v1.15.0; verified live
via published docker images.

Layered defense applied to both handlers:

- rest.SafeImgContentType (in backend/app/rest/) validates sniffed content
  against a strict allowlist: image/png, image/jpeg, image/gif, image/webp,
  image/bmp, image/x-icon. Anything else (HTML, XML, SVG, plain text,
  octet-stream, or any future image type the stdlib sniffer may learn) is
  rejected with no body echo. SVG is implicitly excluded — it sniffs as
  text/xml or text/plain, never image/svg+xml, and SVG can execute scripts
  when navigated to top-level. The previous octet-stream → image/* fallback
  is gone.
- Per-endpoint Content-Security-Policy override sets
  "default-src 'none'; sandbox; frame-ancestors 'none'" on every response
  (success, 304, or error). Sandbox neuters scripts even if Content-Type
  ever regresses. The same policy is also applied to all /api/v1/* via
  apiCSPMiddleware as defense-in-depth.
- Content-Disposition: inline; filename="image" frames the response as a
  file rather than a renderable document.
- /picture/ rejection paths set Cache-Control: no-store so 4xx responses
  are never cached.

The defense headers and the strict ETag matcher are extracted as
rest.SetImageDefenseHeaders and rest.EtagMatches in the shared rest package
(consumed by both proxy/image and api/rest_public — no package cycle).

The /api/v1/img path additionally bumps the ETag to a versioned `"v2:..."`
so revalidating clients (top-level navigation, Ctrl+R, intermediaries) get
a fresh 200 instead of a 304 against poisoned pre-fix cached HTML.

DELIBERATE TRADEOFF: Cache-Control on /api/v1/img success responses remains
max-age=2592000 (30 days), unchanged from before. An aggressive "force
revalidate on every reuse" policy was prototyped during review but reverted
because the perf cost (a server round-trip on every image view, even with
304 saving the body bytes) outweighed the corner-case mitigation. The
realistic exposure of cache carryover is narrow: cache carryover only
affects users who navigated top-level to an attacker URL pre-fix and still
have it in their local cache — the normal <img> embed path cached text/html
but never executed it. Local browser caches that hold pre-fix bytes
continue to serve them until their 30-day TTL expires or are evicted under
memory pressure. The ETag bump reaches all clients that DO revalidate
during the cached lifetime (Ctrl+R, intermediaries, post-expiry use); for
the rest, exposure self-limits via cache expiry. Operators running a
CDN/edge cache in front of remark42 should purge /api/v1/img after deploy.

The /api/v1/img handler short-circuits on a matching current-version
If-None-Match before any store Load or upstream fetch, returning a bodyless
304 with the defense headers set. Safe because the 304 carries no body and
the client's cached bytes came from a prior validated 200; an attacker
fabricating an etag value can only short-circuit fetches for URLs they
themselves crafted. This avoids upstream DoS amplification when clients
revalidate on hot comment pages.

The /api/v1/img route was moved from the "open routes" group (which uses
middleware.NoCache, stripping If-None-Match from incoming requests) to the
"open routes, cached" group alongside /picture/ and /qr/telegram so the
304 revalidation path is no longer broken upstream of the handler.

The /picture/{user}/{id} endpoint does not need the v2 etag prefix. Upload
validates input format via readAndValidateImage and the serve path
re-validates the stored bytes via rest.SafeImgContentType. Bytes within
the resize dimension limits are preserved verbatim, so the browser defense
relies on the response headers (validated Content-Type + nosniff + strict
CSP + Content-Disposition: inline), not on byte normalization.

Global CSP: font-src data: → font-src 'none'. Audit confirmed no @font-face,
no base64 fonts, no icon-font library in the bundle. Drops an unnecessary
attack surface; no behavioural change.

Tests: TestImage_ContentTypeHandling table-tests a real PNG and attack
shapes (HTML claimed as image/png, image/jpeg, image/gif, image/svg+xml,
image/webp; svg with onload; html fragment; polyglot PNG+HTML), proving
the defense holds across arbitrary upstream Content-Type variation.
Polyglot case is intentionally served as image/png — the browser cannot
execute the trailing HTML when the response type is image/png with nosniff.
TestImage_ContentTypeHandling_CacheHit exercises the cache-hit branch with
attacker bytes preloaded into the store. TestImage_PerRequestRevalidation
alternates upstream PNG/HTML across four proxy calls to prove no trust
accumulates between requests. TestImage_RoutesUsingCachedImage asserts
cache-poisoning is caught at serve time. TestImage_EtagVersioned asserts
the v2 prefix invalidates pre-fix etags AND that the revalidation 304
triggers no store Load. TestImage_RevalidationSkipsIO proves the
short-circuit works even with no upstream reachable. TestSafeImgContentType
covers the allowlist directly. TestRest_LoadPictureDefenseHeaders and
TestRest_LoadPictureRejectsNonImage exercise the /picture/ endpoint.
TestRest_apiCSP covers the strict CSP middleware on JSON API + RSS routes;
TestRest_securityHeaders confirms /web/ HTML pages keep the global CSP.

Verified end-to-end against the dev docker image: the original demo URL
(arbitrary HTML claimed as image/png) now returns 415 application/json with
CSP/nosniff/Content-Disposition set, no XSS in the browser.

* fix(security): set Cache-Control: no-store on image-proxy error paths, sync stale route comment

Addresses two review comments on #2067:

1. Cache-Control: max-age=2592000 and Etag were set before the
   load/download/validation block, so 404/400/415 error responses inherited
   the 30-day cache TTL and the versioned etag — a transient failure (or an
   intentionally triggered 415) would be pinned in browser/intermediary
   caches for that TTL, keeping users locked out even after the underlying
   cause was resolved. Now: etag is computed but not set as a header until
   after validation succeeds; error paths route through sendImageProxyError
   which sets Cache-Control: no-store and never sets Etag. The 304
   short-circuit still sets both because that path serves the same validated
   content the client already has cached.

2. The comment at rest.go:282 still described the prototyped
   no-cache/must-revalidate Cache-Control policy that was reverted before
   the PR landed. Updated to match the actual 30-day max-age behavior.

Tests: TestImage_ContentTypeHandling now asserts reject paths carry
Cache-Control: no-store and have no Etag header, and accept paths carry
the max-age=2592000 + v2: etag.
2026-05-20 22:37:25 -05:00

826 lines
35 KiB
Go

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
}{
{
`<p> blah <img src="http://radio-t.com/img.png"/> test</p>`,
[]string{"http://radio-t.com/img.png"},
},
{
`<p> blah <img src="https://radio-t.com/img.png"/> test</p>`,
[]string{},
},
{
`<img src="http://radio-t.com/img2.png"/>`,
[]string{"http://radio-t.com/img2.png"},
},
{
`<img src="http://radio-t.com/img3.png"/> <div>xyz <img src="http://images.pexels.com/67636/img4.jpeg"> </div>`,
[]string{"http://radio-t.com/img3.png", "http://images.pexels.com/67636/img4.jpeg"},
},
{
`<img src="https://radio-t.com/img3.png"/> <div>xyz <img src="http://images.pexels.com/67636/img4.jpeg"> </div>`,
[]string{"http://images.pexels.com/67636/img4.jpeg"},
},
{
`abcd <b>blah</b> <h1>xxx</h1>`,
[]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(`<img src="http://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`,
[]string{"http://radio-t.com/img3.png", "http://images.pexels.com/67636/img4.jpeg"})
assert.Equal(t, `<img src="/img?src=aHR0cDovL3JhZGlvLXQuY29tL2ltZzMucG5n"/> xyz <img src="/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, 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(`<img src="http://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`)
assert.Equal(t, `<img src="/img?src=aHR0cDovL3JhZGlvLXQuY29tL2ltZzMucG5n"/> xyz <img src="/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, r)
r = img.Convert(`<img src="https://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`)
assert.Equal(t, `<img src="https://radio-t.com/img3.png"/> xyz <img src="/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, r)
img = Image{HTTP2HTTPS: true, RoutePath: "/img", RemarkURL: "http://example.com"}
r = img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz`)
assert.Equal(t, `<img src="http://radio-t.com/img3.png"/> xyz`, r, "http:// remark url, no proxy")
img = Image{HTTP2HTTPS: false, RoutePath: "/img"}
r = img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz`)
assert.Equal(t, `<img src="http://radio-t.com/img3.png"/> xyz`, r, "disabled, no proxy")
}
func TestImage_ConvertCachingMode(t *testing.T) {
img := Image{CacheExternal: true, RoutePath: "/img", RemarkURL: "https://remark42.com"}
r := img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`)
assert.Equal(t, `<img src="https://remark42.com/img?src=aHR0cDovL3JhZGlvLXQuY29tL2ltZzMucG5n"/> xyz <img src="https://remark42.com/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, r)
r = img.Convert(`<img src="https://radio-t.com/img3.png"/> xyz <img src="https://images.pexels.com/67636/img4.jpeg">`)
assert.Equal(t, `<img src="https://remark42.com/img?src=aHR0cHM6Ly9yYWRpby10LmNvbS9pbWczLnBuZw=="/> xyz <img src="https://remark42.com/img?src=aHR0cHM6Ly9pbWFnZXMucGV4ZWxzLmNvbS82NzYzNi9pbWc0LmpwZWc=">`, r)
r = img.Convert(`<img src="https://remark42.com/pictures/1.png"/>`)
assert.Equal(t, `<img src="https://remark42.com/pictures/1.png"/>`, r)
img = Image{CacheExternal: false, RoutePath: "/img", RemarkURL: "https://remark42.com"}
r = img.Convert(`<img src="http://radio-t.com/img3.png"/>`)
assert.Equal(t, `<img src="http://radio-t.com/img3.png"/>`, r)
// both Caching and Proxy enabled
img = Image{CacheExternal: true, HTTP2HTTPS: true, RoutePath: "/img", RemarkURL: "https://remark42.com"}
r = img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`)
assert.Equal(t, `<img src="https://remark42.com/img?src=aHR0cDovL3JhZGlvLXQuY29tL2ltZzMucG5n"/> xyz <img src="https://remark42.com/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, 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("<html><body><script>alert(document.domain)</script></body></html>")
// 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("<script>alert(1)</script>")...)
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: "<script>"},
{name: "html body claimed as image/jpeg", upstreamCT: "image/jpeg", body: htmlBody, payloadMarker: "<script>"},
{name: "html body claimed as image/gif", upstreamCT: "image/gif", body: htmlBody, payloadMarker: "<script>"},
// upstream claims svg+xml; body still sniffs as text/html (the stdlib sniffer
// never returns image/svg+xml, see rest.SafeImgContentType godoc).
{name: "html body upstream claims image/svg+xml", upstreamCT: "image/svg+xml", body: htmlBody, payloadMarker: "<script>"},
{name: "html body claimed as image/webp", upstreamCT: "image/webp", body: htmlBody, payloadMarker: "<script>"},
// svg payloads — even if upstream claims a valid image format, the sniffer sees XML/text and we must reject
{
name: "svg with xml declaration and onload",
upstreamCT: "image/png",
body: []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>`),
payloadMarker: "onload",
},
{
name: "html fragment without doctype",
upstreamCT: "image/png",
body: []byte(`<body><img src=x onerror=alert(1)></body>`),
payloadMarker: "onerror",
},
// polyglot — image magic + appended HTML. Sniffs as image/png so we accept and serve as image/png.
// Safety comes from the response headers (Content-Type: image/png + X-Content-Type-Options: nosniff),
// not from body filtering: the bytes round-trip verbatim by design (assertion below). The browser
// cannot execute the trailing HTML when the response type is image/png with nosniff.
{name: "polyglot png+html served as png", upstreamCT: "image/png", body: polyglot, accept: true, wantCT: "image/png"},
}
for _, tt := range tbl {
t.Run(tt.name, func(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", tt.upstreamCT)
_, _ = w.Write(tt.body)
}))
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()
encodedURL := base64.URLEncoding.EncodeToString([]byte(upstream.URL + "/logo.png"))
resp, err := http.Get(ts.URL + "/?src=" + encodedURL)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
// every response — accept or reject — must carry the defense headers
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
assert.Contains(t, resp.Header.Get("Content-Disposition"), "inline")
csp := resp.Header.Get("Content-Security-Policy")
assert.Contains(t, csp, "default-src 'none'", "strict CSP missing")
assert.Contains(t, csp, "sandbox", "CSP sandbox missing")
if tt.accept {
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, tt.wantCT, resp.Header.Get("Content-Type"))
assert.Equal(t, tt.body, body, "body bytes must round-trip")
assert.Contains(t, resp.Header.Get("Cache-Control"), "max-age=2592000",
"validated success path carries the 30-day TTL")
assert.True(t, strings.HasPrefix(resp.Header.Get("Etag"), `"v2:`),
"validated success path carries the versioned etag")
return
}
// reject path
assert.GreaterOrEqual(t, resp.StatusCode, 400, "must reject non-image content")
// reject responses must NOT inherit the success path's long-lived cache
// headers — a transient 4xx would otherwise be pinned in browser/intermediary
// caches alongside the versioned etag for 30 days.
assert.Contains(t, resp.Header.Get("Cache-Control"), "no-store",
"reject path must set Cache-Control: no-store; got %q", resp.Header.Get("Cache-Control"))
assert.NotContains(t, resp.Header.Get("Cache-Control"), "max-age=2592000",
"reject path must not carry the success-path 30-day TTL")
assert.Empty(t, resp.Header.Get("Etag"),
"reject path must not carry the versioned etag (would pin the failure in cache)")
ct := resp.Header.Get("Content-Type")
assert.False(t, strings.HasPrefix(ct, "text/html"),
"reject response must not be text/html; got %q", ct)
assert.NotContains(t, string(body), tt.payloadMarker,
"reject response must not echo attack payload; got body=%q", string(body))
})
}
}
// TestEtagMatches lives in the rest package alongside the shared EtagMatches helper
// (see backend/app/rest/image_headers_test.go). The proxy handler delegates to it.
// TestImage_ContentTypeHandling_CacheHit exercises the cache-hit branch of the handler:
// the StoreMock returns attacker bytes directly, so the upstream is never contacted.
// Without the body-sniff at serve time, pre-fix code would have echoed cached HTML as
// text/html. After the fix the same content-type defense applies on the cache path.
func TestImage_ContentTypeHandling_CacheHit(t *testing.T) {
htmlBody := []byte("<html><body><script>alert(document.domain)</script></body></html>")
polyglot := append(append([]byte{}, gopherPNGBytes()...), []byte("<script>alert(1)</script>")...)
tbl := []struct {
name string
cached []byte
accept bool
wantCT string
payloadMarker string
}{
{name: "html in cache claimed as image/png", cached: htmlBody, payloadMarker: "<script>"},
{name: "polyglot in cache served as png", cached: polyglot, accept: true, wantCT: "image/png"},
}
for _, tt := range tbl {
t.Run(tt.name, func(t *testing.T) {
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) {
return tt.cached, nil
}}
img := Image{
CacheExternal: true,
RemarkURL: "https://demo.remark42.com",
RoutePath: "/api/v1/proxy",
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
// no Transport — cache hit must not reach upstream
}
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
defer ts.Close()
encodedURL := base64.URLEncoding.EncodeToString([]byte("https://attacker.example.com/logo.png"))
resp, err := http.Get(ts.URL + "/?src=" + encodedURL)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, 1, len(imageStore.LoadCalls()), "served from cache")
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
assert.Contains(t, resp.Header.Get("Content-Disposition"), "inline")
assert.Contains(t, resp.Header.Get("Content-Security-Policy"),
"default-src 'none'; sandbox; frame-ancestors 'none'")
if tt.accept {
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, tt.wantCT, resp.Header.Get("Content-Type"))
return
}
assert.GreaterOrEqual(t, resp.StatusCode, 400, "must reject non-image cached content")
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), tt.payloadMarker,
"reject response must not echo cached attack payload")
})
}
}
// TestImage_EtagVersioned proves browser/proxy caches with pre-fix etags (the
// unversioned base64 of src that used to be served alongside text/html bodies)
// no longer satisfy revalidation: the server returns a fresh 200 with image
// content instead of 304-ing the poisoned cached entry. The 30-day Cache-Control
// max-age is unchanged — local browser caches still serving pre-fix bytes within
// their TTL are not reached; the prefix only helps clients that revalidate during
// the cached lifetime (Ctrl+R, intermediaries, post-expiry). See etagVersionPrefix
// godoc for the tradeoff.
func TestImage_EtagVersioned(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()
srcRaw := httpSrv.URL + "/image/img1.png"
encodedSrc := base64.URLEncoding.EncodeToString([]byte(srcRaw))
preFixEtag := `"` + encodedSrc + `"` // what a pre-fix browser would have cached
req, err := http.NewRequest("GET", ts.URL+"/?src="+encodedSrc, http.NoBody)
require.NoError(t, err)
req.Header.Set("If-None-Match", preFixEtag)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode,
"pre-fix etag must NOT validate as 304 — old cached text/html must be replaced")
assert.Equal(t, "image/png", resp.Header.Get("Content-Type"))
assert.NotEqual(t, preFixEtag, resp.Header.Get("Etag"), "new etag must differ from pre-fix")
assert.True(t, strings.HasPrefix(resp.Header.Get("Etag"), `"v2:`), "new etag must carry the version prefix")
cc := resp.Header.Get("Cache-Control")
assert.Contains(t, cc, "max-age=2592000", "success path keeps 30-day TTL for cache efficiency")
// sanity: the NEW etag round-trips as 304 when sent back
loadsBefore := len(imageStore.LoadCalls())
req2, err := http.NewRequest("GET", ts.URL+"/?src="+encodedSrc, http.NoBody)
require.NoError(t, err)
req2.Header.Set("If-None-Match", resp.Header.Get("Etag"))
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
defer resp2.Body.Close()
assert.Equal(t, http.StatusNotModified, resp2.StatusCode, "new etag must validate against itself")
body, _ := io.ReadAll(resp2.Body)
assert.Empty(t, body, "304 must have no body")
// 304 path must skip the store lookup entirely — revalidation must not amplify load
assert.Equal(t, loadsBefore, len(imageStore.LoadCalls()),
"revalidation 304 must not trigger any store Load (avoids upstream DoS amplification)")
// 304 path must still carry the layered defense headers
assert.Equal(t, "nosniff", resp2.Header.Get("X-Content-Type-Options"))
assert.Contains(t, resp2.Header.Get("Content-Disposition"), "inline")
assert.Contains(t, resp2.Header.Get("Content-Security-Policy"), "default-src 'none'")
assert.Contains(t, resp2.Header.Get("Content-Security-Policy"), "sandbox")
}
// TestImage_RevalidationSkipsIO proves that a matching current-version If-None-Match
// short-circuits before any cache lookup or upstream fetch. With no Transport and no
// upstream server reachable, the only way this test can pass with 304 is if Load is
// never called and downloadImage is never attempted. This closes the DoS amplification
// where every reuse on a hot comment page would otherwise re-hit the upstream when
// CacheExternal is false.
func TestImage_RevalidationSkipsIO(t *testing.T) {
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) {
t.Fatal("Load must not be called on the revalidation short-circuit path")
return nil, nil
}}
img := Image{
RemarkURL: "https://demo.remark42.com",
RoutePath: "/api/v1/proxy",
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
// no Transport — any downloadImage attempt would also fail
}
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
defer ts.Close()
encodedSrc := base64.URLEncoding.EncodeToString([]byte("https://example.com/whatever.png"))
currentEtag := `"v2:` + encodedSrc + `"`
req, err := http.NewRequest("GET", ts.URL+"/?src="+encodedSrc, http.NoBody)
require.NoError(t, err)
req.Header.Set("If-None-Match", currentEtag)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotModified, resp.StatusCode,
"matching current-version etag must short-circuit to 304 without I/O")
assert.Equal(t, 0, len(imageStore.LoadCalls()),
"revalidation must not trigger store Load")
body, _ := io.ReadAll(resp.Body)
assert.Empty(t, body, "304 must have no body")
// defense headers must still be set on the short-circuit path
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
assert.Contains(t, resp.Header.Get("Content-Disposition"), "inline")
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "default-src 'none'")
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "sandbox")
assert.Equal(t, currentEtag, resp.Header.Get("Etag"))
assert.Contains(t, resp.Header.Get("Cache-Control"), "max-age=2592000")
}
// TestImage_PerRequestRevalidation proves the defense holds when upstream flips its
// response body between requests (give a real PNG once, HTML next time, etc.). Each
// proxy response is independently validated against the body actually returned, so
// trust never accumulates and an earlier "good" response cannot grant the next one a
// free pass.
func TestImage_PerRequestRevalidation(t *testing.T) {
htmlBody := []byte("<html><script>alert(1)</script></html>")
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), "<script>",
"attacker payload must never appear in response body")
// every response must still carry the defense headers
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
assert.Contains(t, resp.Header.Get("Content-Disposition"), "inline")
assert.Contains(t, resp.Header.Get("Content-Security-Policy"),
"default-src 'none'; sandbox; frame-ancestors 'none'")
})
}
}
func imgHTTPTestsServer(t *testing.T) *httptest.Server {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/image/img1.png" {
t.Log("http img request", r.URL)
w.Header().Add("Content-Length", "1462")
w.Header().Add("Content-Type", "image/png")
_, err := w.Write(gopherPNGBytes())
assert.NoError(t, err)
return
}
if r.URL.Path == "/image/img-slow.png" {
time.Sleep(500 * time.Millisecond)
w.WriteHeader(500)
return
}
t.Log("http img request - not found", r.URL)
w.WriteHeader(404)
}))
return ts
}