snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imageproxy
type Error struct {
error
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imageproxy
import (
"errors"
"io"
"net/http"
"net/url"
"reflect"
"strings"
"sync"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/configservice"
"github.com/mattermost/mattermost/server/public/shared/httpservice"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
var ErrNotEnabled = Error{errors.New("imageproxy.ImageProxy: image proxy not enabled")}
// An ImageProxy is the public interface for Mattermost's image proxy. An instance of ImageProxy should be created
// using MakeImageProxy which requires a configService and an HTTPService provided by the server.
type ImageProxy struct {
ConfigService configservice.ConfigService
configListenerID string
HTTPService httpservice.HTTPService
Logger *mlog.Logger
siteURL *url.URL
lock sync.RWMutex
backend ImageProxyBackend
}
// An ImageProxyBackend provides the functionality for different types of image proxies. An ImageProxy will construct
// the required backend depending on the ImageProxySettings provided by the ConfigService.
type ImageProxyBackend interface {
// GetImage provides a proxied image in response to an HTTP request.
GetImage(w http.ResponseWriter, r *http.Request, imageURL string)
// GetImageDirect returns a proxied image along with its content type.
GetImageDirect(imageURL string) (io.ReadCloser, string, error)
}
func MakeImageProxy(configService configservice.ConfigService, httpService httpservice.HTTPService, logger *mlog.Logger) *ImageProxy {
proxy := &ImageProxy{
ConfigService: configService,
HTTPService: httpService,
Logger: logger,
}
// We deliberately ignore the error because it's from config.json.
// The function returns a nil pointer in case of error, and we handle it when it's used.
siteURL, _ := url.Parse(*configService.Config().ServiceSettings.SiteURL)
proxy.siteURL = siteURL
proxy.configListenerID = proxy.ConfigService.AddConfigListener(proxy.OnConfigChange)
config := proxy.ConfigService.Config()
proxy.backend = proxy.makeBackend(config.ImageProxySettings)
return proxy
}
func (proxy *ImageProxy) makeBackend(proxySettings model.ImageProxySettings) ImageProxyBackend {
if !*proxySettings.Enable {
return nil
}
switch *proxySettings.ImageProxyType {
case model.ImageProxyTypeLocal:
return makeLocalBackend(proxy)
default:
return nil
}
}
func (proxy *ImageProxy) Close() {
proxy.lock.Lock()
defer proxy.lock.Unlock()
proxy.ConfigService.RemoveConfigListener(proxy.configListenerID)
}
func (proxy *ImageProxy) OnConfigChange(oldConfig, newConfig *model.Config) {
if *oldConfig.ServiceSettings.SiteURL != *newConfig.ServiceSettings.SiteURL ||
!reflect.DeepEqual(oldConfig.ImageProxySettings, newConfig.ImageProxySettings) {
proxy.lock.Lock()
defer proxy.lock.Unlock()
siteURL, _ := url.Parse(*newConfig.ServiceSettings.SiteURL)
proxy.siteURL = siteURL
proxy.backend = proxy.makeBackend(newConfig.ImageProxySettings)
}
}
// GetImage takes an HTTP request for an image and requests that image using the image proxy.
func (proxy *ImageProxy) GetImage(w http.ResponseWriter, r *http.Request, imageURL string) {
proxy.lock.RLock()
defer proxy.lock.RUnlock()
if proxy.backend == nil {
w.WriteHeader(http.StatusNotImplemented)
return
}
proxy.backend.GetImage(w, r, imageURL)
}
// GetImageDirect takes the URL of an image and returns the image along with its content type.
func (proxy *ImageProxy) GetImageDirect(imageURL string) (io.ReadCloser, string, error) {
proxy.lock.RLock()
defer proxy.lock.RUnlock()
if proxy.backend == nil {
return nil, "", ErrNotEnabled
}
return proxy.backend.GetImageDirect(imageURL)
}
// GetProxiedImageURL takes the URL of an image and returns a URL that can be used to view that image through the
// image proxy.
func (proxy *ImageProxy) GetProxiedImageURL(imageURL string) string {
if imageURL == "" || proxy.siteURL == nil || strings.HasPrefix(strings.ToLower(imageURL), "data:image/") {
return imageURL
}
// Parse url, return siteURL in case of failure.
// Also if the URL is opaque.
parsedURL, err := url.Parse(imageURL)
if err != nil || parsedURL.Opaque != "" {
return proxy.siteURL.String()
}
// If host is same as siteURL host, return.
if parsedURL.Host == proxy.siteURL.Host {
return parsedURL.String()
}
// Handle protocol-relative URLs.
if parsedURL.Scheme == "" {
parsedURL.Scheme = proxy.siteURL.Scheme
}
// If it's a relative URL, fill up the hostname and return.
if parsedURL.Host == "" {
parsedURL.Host = proxy.siteURL.Host
return parsedURL.String()
}
return proxy.siteURL.String() + "/api/v4/image?url=" + url.QueryEscape(parsedURL.String())
}
// GetUnproxiedImageURL takes the URL of an image on the image proxy and returns the original URL of the image.
func (proxy *ImageProxy) GetUnproxiedImageURL(proxiedURL string) string {
return getUnproxiedImageURL(proxiedURL, *proxy.ConfigService.Config().ServiceSettings.SiteURL)
}
func getUnproxiedImageURL(proxiedURL, siteURL string) string {
if !strings.HasPrefix(proxiedURL, siteURL+"/api/v4/image?url=") {
return proxiedURL
}
parsed, err := url.Parse(proxiedURL)
if err != nil {
return proxiedURL
}
u := parsed.Query()["url"]
if len(u) == 0 {
return proxiedURL
}
return u[0]
}

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imageproxy
import (
"net/url"
"testing"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetProxiedImageURL(t *testing.T) {
siteURL := "https://mattermost.example.com"
parsedURL, err := url.Parse(siteURL)
require.NoError(t, err)
imageURL := "https://mattermost.com/wp-content/uploads/2022/02/logoHorizontal.png"
proxiedURL := "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.com%2Fwp-content%2Fuploads%2F2022%2F02%2FlogoHorizontal.png"
proxy := ImageProxy{siteURL: parsedURL}
for _, test := range []struct {
Name string
Input string
Expected string
}{
{
Name: "should proxy an image",
Input: imageURL,
Expected: proxiedURL,
},
{
Name: "should not proxy a relative image",
Input: "/static/logo.png",
Expected: "https://mattermost.example.com/static/logo.png",
},
{
Name: "should bypass opaque URLs",
Input: "http:xyz123?query",
Expected: siteURL,
},
{
Name: "should not proxy an image on the Mattermost server",
Input: "https://mattermost.example.com/static/logo.png",
Expected: "https://mattermost.example.com/static/logo.png",
},
{
Name: "should not proxy an image that has already been proxied",
Input: proxiedURL,
Expected: proxiedURL,
},
{
Name: "should not bypass protocol relative URLs",
Input: "//mattermost.com/static/logo.png",
Expected: "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.com%2Fstatic%2Flogo.png",
},
{
Name: "should not bypass if the host prefix is same",
Input: "https://mattermost.example.com.anothersite.com/static/logo.png",
Expected: "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.example.com.anothersite.com%2Fstatic%2Flogo.png",
},
{
Name: "should not bypass for user auth URLs",
Input: "https://mattermost.example.com@anothersite.com/static/logo.png",
Expected: "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.example.com%40anothersite.com%2Fstatic%2Flogo.png",
},
{
Name: "should not proxy embedded image",
Input: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",
Expected: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",
},
} {
t.Run(test.Name, func(t *testing.T) {
assert.Equal(t, test.Expected, proxy.GetProxiedImageURL(test.Input))
})
}
}
func TestGetUnproxiedImageURL(t *testing.T) {
siteURL := "https://mattermost.example.com"
imageURL := "https://mattermost.com/wp-content/uploads/2022/02/logoHorizontal.png"
proxiedURL := "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.com%2Fwp-content%2Fuploads%2F2022%2F02%2FlogoHorizontal.png"
for _, test := range []struct {
Name string
Input string
Expected string
}{
{
Name: "should remove proxy",
Input: proxiedURL,
Expected: imageURL,
},
{
Name: "should not remove proxy from a relative image",
Input: "/static/logo.png",
Expected: "/static/logo.png",
},
{
Name: "should not remove proxy from an image on the Mattermost server",
Input: "https://mattermost.example.com/static/logo.png",
Expected: "https://mattermost.example.com/static/logo.png",
},
{
Name: "should not remove proxy from a non-proxied image",
Input: imageURL,
Expected: imageURL,
},
} {
t.Run(test.Name, func(t *testing.T) {
assert.Equal(t, test.Expected, getUnproxiedImageURL(test.Input, siteURL))
})
}
}
func TestOnConfigChange(t *testing.T) {
t.Run("for local proxy, should update site URL when that changes", func(t *testing.T) {
proxy := makeTestLocalProxy()
require.Equal(t, "https://mattermost.example.com", proxy.siteURL.String())
require.Equal(t, "https://mattermost.example.com", proxy.backend.(*LocalBackend).baseURL.String())
newConfig := proxy.ConfigService.Config().Clone()
newConfig.ServiceSettings.SiteURL = new("https://new.example.com")
proxy.ConfigService.(*testutils.StaticConfigService).UpdateConfig(newConfig)
require.Equal(t, "https://new.example.com", proxy.siteURL.String())
require.Equal(t, "https://new.example.com", proxy.backend.(*LocalBackend).baseURL.String())
})
}

View File

@@ -0,0 +1,400 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imageproxy
import (
"bufio"
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/http/httptest"
"net/url"
"path"
"path/filepath"
"regexp"
"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/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",
"image/vnd.microsoft.icon", "image/vnd.ms-modi", "image/vnd.net-fpx", "image/vnd.wap.wbmp",
"image/vnd.xiff", "image/webp", "image/x-cmu-raster", "image/x-cmx", "image/x-icon",
"image/x-macpaint", "image/x-pcx", "image/x-pict", "image/x-portable-anymap",
"image/x-portable-bitmap", "image/x-portable-graymap", "image/x-portable-pixmap",
"image/x-quicktime", "image/x-rgb", "image/x-xbitmap", "image/x-xpixmap", "image/x-xwindowdump",
}
var msgNotAllowed = "requested URL is not allowed"
var ErrLocalRequestFailed = Error{errors.New("imageproxy.LocalBackend: failed to request proxied image")}
var ErrImageTooLarge = Error{errors.New("imageproxy.LocalBackend: image exceeds maximum allowed size")}
// maxImageSize caps how many bytes of a remote response GetImageDirect will
// buffer into its in-memory recorder (var, not const, so tests can override it).
// GetImage streams to a real connection instead of buffering, so it's unaffected.
var maxImageSize int64 = 1024 * 1024 * 50 // 50 MiB, matching app.MaxMetadataImageSize
type LocalBackend struct {
client *http.Client
baseURL *url.URL
}
// URLError reports a malformed URL error.
type URLError struct {
Message string
URL *url.URL
}
func (e URLError) Error() string {
return fmt.Sprintf("malformed URL %q: %s", e.URL, e.Message)
}
func makeLocalBackend(proxy *ImageProxy) *LocalBackend {
baseURL := proxy.siteURL
if baseURL == nil {
mlog.Warn("Failed to set base URL for image proxy. Relative image links may not work.")
}
client := proxy.HTTPService.MakeClient(false)
return &LocalBackend{
client: client,
baseURL: baseURL,
}
}
type contentTypeRecorder struct {
http.ResponseWriter
filename string
}
func (rec *contentTypeRecorder) WriteHeader(code int) {
hdr := rec.ResponseWriter.Header()
contentType := hdr.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
// The error is caused by a malformed input and there's not much use logging it.
// Therefore, even in the error case we set it to attachment mode to be safe.
if err != nil || mediaType == "image/svg+xml" {
hdr.Set("Content-Disposition", fmt.Sprintf("attachment;filename=%q", rec.filename))
}
rec.ResponseWriter.WriteHeader(code)
}
func (backend *LocalBackend) GetImage(w http.ResponseWriter, r *http.Request, imageURL string) {
// The interface to the proxy only exposes a ServeHTTP method, so fake a request to it
req, err := http.NewRequest(http.MethodGet, "/"+imageURL, nil)
if err != nil {
// http.NewRequest should only return an error on an invalid URL
mlog.Debug("Failed to create request for proxied image", mlog.String("url", imageURL), mlog.Err(err))
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte{})
return
}
u, err := url.Parse(imageURL)
if err != nil {
mlog.Debug("Failed to parse URL for proxied image", mlog.String("url", imageURL), mlog.Err(err))
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte{})
return
}
w.Header().Set("X-Frame-Options", "deny")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src data:; style-src 'unsafe-inline'")
rec := contentTypeRecorder{w, filepath.Base(u.Path)}
backend.ServeImage(&rec, req, 0)
}
func (backend *LocalBackend) GetImageDirect(imageURL string) (io.ReadCloser, string, error) {
// The interface to the proxy only exposes a ServeHTTP method, so fake a request to it
req, err := http.NewRequest(http.MethodGet, "/"+imageURL, nil)
if err != nil {
return nil, "", Error{err}
}
recorder := httptest.NewRecorder()
if truncated := backend.ServeImage(recorder, req, maxImageSize); truncated {
// Log only the host, not the full URL: the path/query may carry
// sensitive tokens or signed parameters that shouldn't hit the logs.
fields := []mlog.Field{mlog.Int("max_bytes", maxImageSize)}
if parsed, parseErr := url.Parse(imageURL); parseErr == nil {
fields = append(fields, mlog.String("host", parsed.Host))
}
mlog.Warn("Discarding proxied image that exceeded max size for direct fetch", fields...)
return nil, "", ErrImageTooLarge
}
if recorder.Code != http.StatusOK {
return nil, "", ErrLocalRequestFailed
}
return io.NopCloser(recorder.Body), recorder.Header().Get("Content-Type"), nil
}
// ServeImage fetches the remote image referenced by req and writes it to w.
// maxBytes limits how many bytes of the response body are copied to w; pass 0
// to disable the limit. The return value reports whether the body was truncated
// (always false when maxBytes is 0).
func (backend *LocalBackend) ServeImage(w http.ResponseWriter, req *http.Request, maxBytes int64) (truncated bool) {
proxyReq, err := newProxyRequest(req, backend.baseURL)
if err != nil {
http.Error(w, fmt.Sprintf("invalid request URL: %v", err), http.StatusBadRequest)
return false
}
actualReq, err := http.NewRequest("GET", proxyReq.String(), nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return false
}
actualReq.Header.Set("Accept", strings.Join(imageContentTypes, ", "))
resp, err := backend.client.Do(actualReq)
if err != nil {
mlog.Warn("error fetching remote image", mlog.Err(err))
statusCode := http.StatusInternalServerError
if e, ok := err.(net.Error); ok && e.Timeout() {
statusCode = http.StatusGatewayTimeout
}
http.Error(w, fmt.Sprintf("error fetching remote image: %v", err), statusCode)
return false
}
// close the original resp.Body, even if we wrap it in a NopCloser below
defer resp.Body.Close()
copyHeader(w.Header(), resp.Header, "Cache-Control", "Last-Modified", "Expires", "Etag", "Link")
// 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 false
}
contentType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if contentType == "" || contentType == "application/octet-stream" || contentType == "binary/octet-stream" {
contentType = peekContentType(b)
}
if resp.ContentLength != 0 && !contentTypeMatches(imageContentTypes, contentType) {
http.Error(w, msgNotAllowed, http.StatusForbidden)
return false
}
if should304(req, resp) {
w.WriteHeader(http.StatusNotModified)
return false
}
w.Header().Set("Content-Type", contentType)
copyHeader(w.Header(), resp.Header, "Content-Length")
// Enable CORS for 3rd party applications
w.Header().Set("Access-Control-Allow-Origin", "*")
// Add a Content-Security-Policy to prevent stored-XSS attacks via SVG files
w.Header().Set("Content-Security-Policy", "script-src 'none'")
// Disable Content-Type sniffing
w.Header().Set("X-Content-Type-Options", "nosniff")
// Block potential XSS attacks especially in legacy browsers which do not support CSP
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.WriteHeader(resp.StatusCode)
var body io.Reader = resp.Body
if maxBytes > 0 {
// Read one byte past the limit so a source that has more data than
// maxBytes can be distinguished from one that has exactly maxBytes.
body = io.LimitReader(resp.Body, maxBytes+1)
}
n, err := io.Copy(w, body)
if err != nil {
mlog.Warn("error copying response", mlog.Err(err))
}
return maxBytes > 0 && n > maxBytes
}
// copyHeader copies header values from src to dst, adding to any existing
// values with the same header name. If keys is not empty, only those header
// keys will be copied.
func copyHeader(dst, src http.Header, keys ...string) {
if len(keys) == 0 {
for k := range src {
keys = append(keys, k)
}
}
for _, key := range keys {
k := http.CanonicalHeaderKey(key)
for _, v := range src[k] {
dst.Add(k, v)
}
}
}
func should304(req *http.Request, resp *http.Response) bool {
etag := resp.Header.Get("Etag")
if etag != "" && etag == req.Header.Get("If-None-Match") {
return true
}
lastModified, err := time.Parse(time.RFC1123, resp.Header.Get("Last-Modified"))
if err != nil {
return false
}
ifModSince, err := time.Parse(time.RFC1123, req.Header.Get("If-Modified-Since"))
if err != nil {
return false
}
if lastModified.Before(ifModSince) || lastModified.Equal(ifModSince) {
return true
}
return false
}
// peekContentType peeks at the first 512 bytes of p, and attempts to detect
// the content type. Returns empty string if error occurs.
func peekContentType(p *bufio.Reader) string {
byt, err := p.Peek(512)
if err != nil && err != bufio.ErrBufferFull && err != io.EOF {
return ""
}
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, "<svg") ||
(strings.Contains(lower, "<?xml") && strings.Contains(lower, "<svg")) {
return true
}
}
// Raw-byte scan for UTF-8 / ASCII content; interleaved-NUL patterns cover BOM-less UTF-16 BE.
rawLower := strings.ToLower(string(byt))
return strings.Contains(rawLower, "<svg") ||
(strings.Contains(rawLower, "<?xml") && strings.Contains(rawLower, "<svg")) ||
strings.Contains(rawLower, "<\x00s\x00v\x00g\x00") || // BOM-less UTF-16 LE
strings.Contains(rawLower, "\x00<\x00s\x00v\x00g") // BOM-less UTF-16 BE
}
// contentTypeMatches returns whether contentType matches one of the allowed patterns.
func contentTypeMatches(patterns []string, contentType string) bool {
if len(patterns) == 0 {
return true
}
for _, pattern := range patterns {
if ok, err := path.Match(pattern, contentType); ok && err == nil {
return true
}
}
return false
}
// proxyRequest is an imageproxy request which includes a remote URL of an image to
// proxy.
type proxyRequest struct {
URL *url.URL // URL of the image to proxy
Original *http.Request // The original HTTP request
}
// String returns the request URL as a string, with r.Options encoded in the
// URL fragment.
func (r proxyRequest) String() string {
return r.URL.String()
}
func newProxyRequest(r *http.Request, baseURL *url.URL) (*proxyRequest, error) {
var err error
req := &proxyRequest{Original: r}
path := r.URL.EscapedPath()[1:] // strip leading slash
req.URL, err = parseURL(path)
if err != nil || !req.URL.IsAbs() {
// first segment should be options
parts := strings.SplitN(path, "/", 2)
if len(parts) != 2 {
return nil, URLError{"too few path segments", r.URL}
}
var err error
req.URL, err = parseURL(parts[1])
if err != nil {
return nil, URLError{fmt.Sprintf("unable to parse remote URL: %v", err), r.URL}
}
}
if baseURL != nil {
req.URL = baseURL.ResolveReference(req.URL)
}
if !req.URL.IsAbs() {
return nil, URLError{"must provide absolute remote URL", r.URL}
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return nil, URLError{"remote URL must have http or https scheme", r.URL}
}
// query string is always part of the remote URL
req.URL.RawQuery = r.URL.RawQuery
return req, nil
}
var reCleanedURL = regexp.MustCompile(`^(https?):/+([^/])`)
// parseURL parses s as a URL, handling URLs that have been munged by
// path.Clean or a webserver that collapses multiple slashes.
func parseURL(s string) (*url.URL, error) {
s = reCleanedURL.ReplaceAllString(s, "$1://$2")
return url.Parse(s)
}

View File

@@ -0,0 +1,527 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imageproxy
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/httpservice"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
)
func makeTestLocalProxy() *ImageProxy {
configService := &testutils.StaticConfigService{
Cfg: &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://mattermost.example.com"),
AllowedUntrustedInternalConnections: new("127.0.0.1"),
},
ImageProxySettings: model.ImageProxySettings{
Enable: new(true),
ImageProxyType: model.NewPointer(model.ImageProxyTypeLocal),
},
},
}
return MakeImageProxy(configService, httpservice.MakeHTTPService(configService), nil)
}
func TestLocalBackend_GetImage(t *testing.T) {
t.Run("image", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
w.Write([]byte("1111111111"))
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "max-age=2592000, private", resp.Header.Get("Cache-Control"))
assert.Equal(t, "10", resp.Header.Get("Content-Length"))
respBody, _ := io.ReadAll(resp.Body)
assert.Equal(t, []byte("1111111111"), respBody)
})
t.Run("not an image", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotAcceptable)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/file.pdf")
resp := recorder.Result()
require.Equal(t, http.StatusNotAcceptable, resp.StatusCode)
})
t.Run("not an image, but remote server ignores accept header", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
w.Write([]byte("1111111111"))
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/file.pdf")
resp := recorder.Result()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("not found", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("other server error", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
})
t.Run("timeout", func(t *testing.T) {
wait := make(chan bool, 1)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-wait
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
// Modify the timeout to be much shorter than the default 30 seconds
proxy.backend.(*LocalBackend).client.Timeout = time.Millisecond
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
require.Equal(t, http.StatusGatewayTimeout, resp.StatusCode)
wait <- true
})
t.Run("unsupported SVG content type", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
w.Write([]byte("1111111111"))
})
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+"/test.svg")
resp := recorder.Result()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("SVG body with image/png content type", func(t *testing.T) {
body := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>`)
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(`<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`)
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 := `<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`
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 := `<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`
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(`<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`)...)
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) {
var mock *httptest.Server
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/image.png":
w.Header().Set("Location", mock.URL+"/image2.png")
w.WriteHeader(http.StatusMovedPermanently)
case "/image2.png":
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
w.Write([]byte("1111111111"))
}
})
mock = httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "10", resp.Header.Get("Content-Length"))
})
}
func TestLocalBackend_GetImageDirect(t *testing.T) {
t.Run("image", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
w.Write([]byte("1111111111"))
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
assert.NoError(t, err)
assert.Equal(t, "image/png", contentType)
respBody, _ := io.ReadAll(body)
assert.Equal(t, []byte("1111111111"), respBody)
})
t.Run("not an image", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotAcceptable)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/file.pdf")
assert.Error(t, err)
assert.Equal(t, "", contentType)
assert.Equal(t, ErrLocalRequestFailed, err)
assert.Nil(t, body)
})
t.Run("not an image, but remote server ignores accept header", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
w.Write([]byte("1111111111"))
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/file.pdf")
assert.Error(t, err)
assert.Equal(t, "", contentType)
assert.Equal(t, ErrLocalRequestFailed, err)
assert.Nil(t, body)
})
t.Run("not found", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
assert.Error(t, err)
assert.Equal(t, "", contentType)
assert.Equal(t, ErrLocalRequestFailed, err)
assert.Nil(t, body)
})
t.Run("other server error", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
assert.Error(t, err)
assert.Equal(t, "", contentType)
assert.Equal(t, ErrLocalRequestFailed, err)
assert.Nil(t, body)
})
t.Run("timeout", func(t *testing.T) {
wait := make(chan bool, 1)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-wait
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
// Modify the timeout to be much shorter than the default 30 seconds
proxy.backend.(*LocalBackend).client.Timeout = time.Millisecond
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
assert.Error(t, err)
assert.Equal(t, "", contentType)
assert.Equal(t, ErrLocalRequestFailed, err)
assert.Nil(t, body)
wait <- true
})
t.Run("image exceeds max size", func(t *testing.T) {
originalMaxImageSize := maxImageSize
maxImageSize = 10
defer func() { maxImageSize = originalMaxImageSize }()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.WriteHeader(http.StatusOK)
w.Write(bytes.Repeat([]byte("1"), int(maxImageSize)+1))
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
assert.Error(t, err)
assert.Equal(t, ErrImageTooLarge, err)
assert.Equal(t, "", contentType)
assert.Nil(t, body)
})
t.Run("image within max size is unaffected", func(t *testing.T) {
originalMaxImageSize := maxImageSize
maxImageSize = 10
defer func() { maxImageSize = originalMaxImageSize }()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.WriteHeader(http.StatusOK)
w.Write(bytes.Repeat([]byte("1"), int(maxImageSize)))
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
assert.NoError(t, err)
assert.Equal(t, "image/png", contentType)
respBody, _ := io.ReadAll(body)
assert.Equal(t, bytes.Repeat([]byte("1"), int(maxImageSize)), respBody)
})
}