From 5eceedaa898f735f87e7db6fa00be67066598855 Mon Sep 17 00:00:00 2001 From: Mattermost Build Date: Fri, 20 Mar 2026 07:31:11 +0100 Subject: [PATCH] Automated cherry pick of #35669 (#35695) Automatic Merge --- server/go.mod | 2 +- server/platform/services/imageproxy/local.go | 58 +++++++- .../services/imageproxy/local_test.go | 131 +++++++++++++++++- 3 files changed, 180 insertions(+), 11 deletions(-) diff --git a/server/go.mod b/server/go.mod index 8a36326d30..f98b5f292e 100644 --- a/server/go.mod +++ b/server/go.mod @@ -74,6 +74,7 @@ require ( golang.org/x/net v0.43.0 golang.org/x/sync v0.17.0 golang.org/x/term v0.34.0 + golang.org/x/text v0.29.0 gopkg.in/mail.v2 v2.3.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -223,7 +224,6 @@ require ( golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.29.0 // indirect golang.org/x/tools v0.36.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 // indirect google.golang.org/grpc v1.72.0 // indirect diff --git a/server/platform/services/imageproxy/local.go b/server/platform/services/imageproxy/local.go index 33b8169e2e..af8804d874 100644 --- a/server/platform/services/imageproxy/local.go +++ b/server/platform/services/imageproxy/local.go @@ -19,12 +19,15 @@ import ( "strings" "time" + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" + "github.com/mattermost/mattermost/server/public/shared/mlog" ) var imageContentTypes = []string{ "image/bmp", "image/cgm", "image/g3fax", "image/gif", "image/ief", "image/jp2", - "image/jpeg", "image/jpg", "image/pict", "image/png", "image/prs.btif", "image/svg+xml", + "image/jpeg", "image/jpg", "image/pict", "image/png", "image/prs.btif", "image/tiff", "image/vnd.adobe.photoshop", "image/vnd.djvu", "image/vnd.dwg", "image/vnd.dxf", "image/vnd.fastbidsheet", "image/vnd.fpx", "image/vnd.fst", "image/vnd.fujixerox.edmics-mmr", "image/vnd.fujixerox.edmics-rlc", @@ -162,22 +165,30 @@ func (backend *LocalBackend) ServeImage(w http.ResponseWriter, req *http.Request copyHeader(w.Header(), resp.Header, "Cache-Control", "Last-Modified", "Expires", "Etag", "Link") - if should304(req, resp) { - w.WriteHeader(http.StatusNotModified) + // Wrap the body in a bufio.Reader so we can peek at bytes for + // content-type detection without consuming the stream. + b := bufio.NewReaderSize(resp.Body, contentPeekSize) + resp.Body = io.NopCloser(b) + + if isSVGContent(b) { + http.Error(w, msgNotAllowed, http.StatusForbidden) return } contentType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) if contentType == "" || contentType == "application/octet-stream" || contentType == "binary/octet-stream" { - // try to detect content type - b := bufio.NewReader(resp.Body) - resp.Body = io.NopCloser(b) contentType = peekContentType(b) } if resp.ContentLength != 0 && !contentTypeMatches(imageContentTypes, contentType) { http.Error(w, msgNotAllowed, http.StatusForbidden) return } + + if should304(req, resp) { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("Content-Type", contentType) copyHeader(w.Header(), resp.Header, "Content-Length") @@ -248,6 +259,41 @@ func peekContentType(p *bufio.Reader) string { return http.DetectContentType(byt) } +// contentPeekSize is the number of bytes read ahead for content inspection. +// It must match the bufio.Reader buffer size created in ServeImage. +const contentPeekSize = 8192 + +// isSVGContent peeks at the first contentPeekSize bytes of p and reports whether +// they contain SVG markers. UTF-16 encoded content (identified by a BOM) is +// decoded to ASCII before scanning. +func isSVGContent(p *bufio.Reader) bool { + byt, err := p.Peek(contentPeekSize) + if err != nil && err != bufio.ErrBufferFull && err != io.EOF { + return false + } + if len(byt) == 0 { + return false + } + + // UseBOM selects endianness from a BOM when present (0xFF 0xFE → LE, + // 0xFE 0xFF → BE), defaulting to LE otherwise. + enc := unicode.UTF16(unicode.LittleEndian, unicode.UseBOM) + if decoded, _, decodeErr := transform.Bytes(enc.NewDecoder(), byt); decodeErr == nil { + lower := strings.ToLower(string(decoded)) + if strings.Contains(lower, "`) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + + w.WriteHeader(http.StatusOK) + w.Write(body) + }) + + mock := httptest.NewServer(handler) + defer mock.Close() + + proxy := makeTestLocalProxy() + + recorder := httptest.NewRecorder() + request, err := http.NewRequest(http.MethodGet, "", nil) require.NoError(t, err) + proxy.GetImage(recorder, request, mock.URL+"/image.png") + resp := recorder.Result() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + t.Run("XML-based SVG with image/png content type", func(t *testing.T) { + body := []byte(``) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + + w.WriteHeader(http.StatusOK) + w.Write(body) + }) + + mock := httptest.NewServer(handler) + defer mock.Close() + + proxy := makeTestLocalProxy() + + recorder := httptest.NewRecorder() + request, err := http.NewRequest(http.MethodGet, "", nil) + require.NoError(t, err) + proxy.GetImage(recorder, request, mock.URL+"/image.png") + resp := recorder.Result() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + t.Run("UTF-16 LE BOM SVG with image/png content type", func(t *testing.T) { + // Build a UTF-16 LE payload with BOM: 0xFF 0xFE followed by each ASCII + // character of the SVG tag as a two-byte little-endian code unit. + svgASCII := `` + body := []byte{0xFF, 0xFE} // UTF-16 LE BOM + for _, c := range svgASCII { + body = append(body, byte(c), 0x00) + } + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.WriteHeader(http.StatusOK) + w.Write(body) + }) + + mock := httptest.NewServer(handler) + defer mock.Close() + + proxy := makeTestLocalProxy() + + recorder := httptest.NewRecorder() + request, err := http.NewRequest(http.MethodGet, "", nil) + require.NoError(t, err) + proxy.GetImage(recorder, request, mock.URL+"/image.png") + resp := recorder.Result() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + t.Run("UTF-16 BE BOM SVG with image/png content type", func(t *testing.T) { + // Build a UTF-16 BE payload with BOM: 0xFE 0xFF followed by each ASCII + // character as a two-byte big-endian code unit. + svgASCII := `` + body := []byte{0xFE, 0xFF} // UTF-16 BE BOM + for _, c := range svgASCII { + body = append(body, 0x00, byte(c)) + } + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.WriteHeader(http.StatusOK) + w.Write(body) + }) + + mock := httptest.NewServer(handler) + defer mock.Close() + + proxy := makeTestLocalProxy() + + recorder := httptest.NewRecorder() + request, err := http.NewRequest(http.MethodGet, "", nil) + require.NoError(t, err) + proxy.GetImage(recorder, request, mock.URL+"/image.png") + resp := recorder.Result() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + t.Run("SVG body with leading whitespace prefix", func(t *testing.T) { + prefix := bytes.Repeat([]byte(" "), 600) + body := append(prefix, []byte(``)...) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + + w.WriteHeader(http.StatusOK) + w.Write(body) + }) + + mock := httptest.NewServer(handler) + defer mock.Close() + + proxy := makeTestLocalProxy() + + recorder := httptest.NewRecorder() + request, err := http.NewRequest(http.MethodGet, "", nil) + require.NoError(t, err) + proxy.GetImage(recorder, request, mock.URL+"/image.png") + resp := recorder.Result() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) }) t.Run("Redirect", func(t *testing.T) {