[MM-69810] Update golang.org/x/image dep (#37595)

This commit is contained in:
Ibrahim Serdar Acikgoz
2026-07-28 13:11:38 +02:00
committed by GitHub
parent dc41b72fdd
commit d33ad5a7e9
11 changed files with 194 additions and 35 deletions
@@ -280,8 +280,11 @@ test.describe('Post height', () => {
},
{
name: 'post with an SVG Markdown image',
// TODO Either Chrome preloads the SVG's dimensions early or Firefox doesn't allocate the height properly
skipProjects: ['firefox'],
// Markdown/remote SVGs intentionally receive no server-provided dimensions
// (SVG images are filtered from link metadata to mitigate the MM-67372 DoS),
// so the client cannot reserve height before the SVG loads. That makes a
// layout-shift-free render impossible for this case regardless of browser.
skipProjects: ['chrome', 'firefox', 'ipad'],
getSeedOptions: (baseUrl) => ({
message: `![icon](${baseUrl}/icon.svg)`,
}),
+2 -1
View File
@@ -222,7 +222,8 @@ func NewChannels(s *Server) (*Channels, error) {
decoderConcurrency = runtime.NumCPU()
}
ch.imgDecoder, imgErr = imaging.NewDecoder(imaging.DecoderOptions{
ConcurrencyLevel: decoderConcurrency,
ConcurrencyLevel: decoderConcurrency,
MaxDecodedResolution: *ch.cfgSvc.Config().FileSettings.MaxImageResolution,
})
if imgErr != nil {
return nil, errors.Wrap(imgErr, "failed to create image decoder")
+81
View File
@@ -4,6 +4,7 @@
package imaging
import (
"bytes"
"errors"
"fmt"
"image"
@@ -23,6 +24,13 @@ type DecoderOptions struct {
// The level of concurrency for the decoder. This defines a limit on the
// number of concurrently running encoding goroutines.
ConcurrencyLevel int
// MaxDecodedResolution, when greater than zero, is the maximum number of
// pixels (width*height) an image may declare before it is decoded. Images
// exceeding this limit are rejected up front. This is a defense-in-depth
// guard against decompression bombs that bounds server-side memory
// allocation regardless of the underlying codec's behavior.
MaxDecodedResolution int64
}
func (o *DecoderOptions) validate() error {
@@ -52,8 +60,76 @@ func NewDecoder(opts DecoderOptions) (*Decoder, error) {
return &d, nil
}
// enforceResolutionLimit inspects the image header and rejects images whose
// declared resolution exceeds the configured MaxDecodedResolution before any
// pixel data is decoded. It returns the reader to use for the subsequent full
// decode: seekable readers are rewound to their original position, while
// non-seekable readers are buffered so the cap is enforced for every input.
func (d *Decoder) enforceResolutionLimit(rd io.Reader) (io.Reader, error) {
if d.opts.MaxDecodedResolution <= 0 {
return rd, nil
}
if seeker, ok := rd.(io.ReadSeeker); ok {
// Preserve the caller's position so an image decoded from a non-zero
// offset still lines up for the full decode.
start, err := seeker.Seek(0, io.SeekCurrent)
if err != nil {
return nil, fmt.Errorf("imaging: failed to read image position: %w", err)
}
cfg, _, cfgErr := image.DecodeConfig(seeker)
if _, err := seeker.Seek(start, io.SeekStart); err != nil {
return nil, fmt.Errorf("imaging: failed to seek after reading image config: %w", err)
}
if err := d.checkConfigResolution(cfg, cfgErr); err != nil {
return nil, err
}
return rd, nil
}
// Non-seekable reader: buffer the input so the resolution cap can still be
// enforced and the data can be decoded afterwards.
data, err := io.ReadAll(rd)
if err != nil {
return nil, fmt.Errorf("imaging: failed to read image data: %w", err)
}
cfg, _, cfgErr := image.DecodeConfig(bytes.NewReader(data))
if err := d.checkConfigResolution(cfg, cfgErr); err != nil {
return nil, err
}
return bytes.NewReader(data), nil
}
// checkConfigResolution rejects a decoded image config whose resolution exceeds
// the configured cap. A config-decode error is ignored so the subsequent full
// decode can surface a meaningful error for malformed input.
func (d *Decoder) checkConfigResolution(cfg image.Config, cfgErr error) error {
if cfgErr != nil {
return nil
}
if exceedsResolution(int64(cfg.Width), int64(cfg.Height), d.opts.MaxDecodedResolution) {
return fmt.Errorf("imaging: image resolution %dx%d exceeds the maximum allowed %d pixels", cfg.Width, cfg.Height, d.opts.MaxDecodedResolution)
}
return nil
}
// exceedsResolution reports whether width*height exceeds maxRes. It divides
// instead of multiplying so it can't overflow int64 for very large declared
// dimensions.
func exceedsResolution(width, height, maxRes int64) bool {
if width <= 0 || height <= 0 {
return false
}
return width > maxRes/height
}
// Decode decodes the given encoded data and returns the decoded image.
func (d *Decoder) Decode(rd io.Reader) (img image.Image, format string, err error) {
rd, err = d.enforceResolutionLimit(rd)
if err != nil {
return nil, "", err
}
if d.opts.ConcurrencyLevel != 0 {
d.sem <- struct{}{}
defer func() { <-d.sem }()
@@ -71,6 +147,11 @@ func (d *Decoder) Decode(rd io.Reader) (img image.Image, format string, err erro
// must be called when access to the raw image is not needed anymore.
// This sets the raw image data pointer to nil in an attempt to help the GC to re-use the underlying data as soon as possible.
func (d *Decoder) DecodeMemBounded(rd io.Reader) (img image.Image, format string, releaseFunc func(), err error) {
rd, err = d.enforceResolutionLimit(rd)
if err != nil {
return nil, "", nil, err
}
if d.opts.ConcurrencyLevel != 0 {
d.sem <- struct{}{}
defer func() {
@@ -5,6 +5,9 @@ package imaging
import (
"bytes"
"image"
"image/png"
"io"
"os"
"sync"
"testing"
@@ -256,3 +259,87 @@ func TestDecoderDecodeMemBounded(t *testing.T) {
require.Empty(t, d.sem)
})
}
// TestDecoderMaxDecodedResolution verifies the defense-in-depth cap: the shared
// decoder refuses to decode any image whose declared resolution exceeds the
// configured limit, regardless of the underlying codec, before allocating
// pixel data.
func TestDecoderMaxDecodedResolution(t *testing.T) {
makePNG := func(w, h int) []byte {
var buf bytes.Buffer
require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, h))))
return buf.Bytes()
}
d, err := NewDecoder(DecoderOptions{MaxDecodedResolution: 100})
require.NoError(t, err)
t.Run("Decode rejects image exceeding the cap", func(t *testing.T) {
img, format, decErr := d.Decode(bytes.NewReader(makePNG(50, 50))) // 2500px > 100
require.Error(t, decErr)
require.ErrorContains(t, decErr, "exceeds the maximum allowed")
require.Nil(t, img)
require.Empty(t, format)
})
t.Run("Decode allows image within the cap", func(t *testing.T) {
img, format, decErr := d.Decode(bytes.NewReader(makePNG(5, 5))) // 25px <= 100
require.NoError(t, decErr)
require.NotNil(t, img)
require.Equal(t, "png", format)
})
t.Run("DecodeMemBounded rejects image exceeding the cap", func(t *testing.T) {
img, format, release, decErr := d.DecodeMemBounded(bytes.NewReader(makePNG(50, 50)))
require.Error(t, decErr)
require.ErrorContains(t, decErr, "exceeds the maximum allowed")
require.Nil(t, img)
require.Empty(t, format)
require.Nil(t, release)
})
t.Run("cap disabled by default", func(t *testing.T) {
dd, ddErr := NewDecoder(DecoderOptions{})
require.NoError(t, ddErr)
img, _, decErr := dd.Decode(bytes.NewReader(makePNG(50, 50)))
require.NoError(t, decErr)
require.NotNil(t, img)
})
// A non-seekable reader must still be subject to the cap; the decoder
// buffers it internally rather than silently bypassing the check.
t.Run("cap enforced on non-seekable reader", func(t *testing.T) {
// io.MultiReader is not an io.ReadSeeker.
img, format, decErr := d.Decode(io.MultiReader(bytes.NewReader(makePNG(50, 50))))
require.Error(t, decErr)
require.ErrorContains(t, decErr, "exceeds the maximum allowed")
require.Nil(t, img)
require.Empty(t, format)
})
t.Run("non-seekable reader within cap decodes from buffer", func(t *testing.T) {
img, format, decErr := d.Decode(io.MultiReader(bytes.NewReader(makePNG(5, 5))))
require.NoError(t, decErr)
require.NotNil(t, img)
require.Equal(t, "png", format)
})
}
// TestExceedsResolution verifies the resolution comparison rejects over-limit
// images (including dimensions large enough to overflow a naive int64
// multiplication) without wrapping around.
func TestExceedsResolution(t *testing.T) {
const maxRes = int64(7680 * 4320) // default 8K cap, ~33 MPx
require.False(t, exceedsResolution(100, 100, maxRes))
require.False(t, exceedsResolution(7680, 4320, maxRes)) // exactly at the cap
require.True(t, exceedsResolution(10000, 10000, maxRes))
// width*height here (2^80) overflows int64; the division-based check must
// still reject it rather than wrap to a small/negative value.
require.True(t, exceedsResolution(1<<40, 1<<40, maxRes))
// Non-positive dimensions are treated as not exceeding the cap.
require.False(t, exceedsResolution(0, 100, maxRes))
require.False(t, exceedsResolution(100, 0, maxRes))
}
+4 -3
View File
@@ -9,7 +9,6 @@ import (
"encoding/json"
"errors"
"fmt"
"image"
"io"
"mime/multipart"
"net/http"
@@ -2354,8 +2353,10 @@ func (a *App) SetTeamIconFromMultiPartFile(rctx request.CTX, teamID string, file
}
func (a *App) SetTeamIconFromFile(rctx request.CTX, team *model.Team, file io.ReadSeeker) *model.AppError {
// Decode image into Image object
img, format, err := image.Decode(file)
// Decode image into Image object using the shared decoder so team icons
// are subject to the same concurrency and resolution safeguards as other
// user-uploaded images.
img, format, err := a.ch.imgDecoder.Decode(file)
if err != nil {
return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.decode.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
+5 -5
View File
@@ -78,12 +78,12 @@ require (
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c
github.com/yuin/goldmark v1.8.2
golang.org/x/crypto v0.53.0
golang.org/x/image v0.40.0
golang.org/x/image v0.44.0
golang.org/x/net v0.56.0
golang.org/x/sync v0.21.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
golang.org/x/text v0.38.0
golang.org/x/text v0.40.0
)
require (
@@ -220,8 +220,8 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/grpc v1.81.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
+10 -10
View File
@@ -716,8 +716,8 @@ golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsi
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -727,8 +727,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -776,8 +776,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -839,8 +839,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
@@ -856,8 +856,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -12,7 +12,6 @@ exports[`components/SizeAwareImage should load download and copy link buttons wh
class="image-loading__container"
>
<img
aria-label="file thumbnail photo-1533709752211-118fcaf03312"
class="image-loading__placeholder class"
height="200"
role="presentation"
@@ -97,7 +96,6 @@ exports[`components/SizeAwareImage should match snapshot when handleSmallImageCo
class="image-loading__container"
>
<img
aria-label="file thumbnail photo-1533709752211-118fcaf03312"
class="image-loading__placeholder class"
height="200"
role="presentation"
@@ -202,7 +200,6 @@ exports[`components/SizeAwareImage should render a placeholder and has loader wh
</div>
</div>
<img
aria-label="file thumbnail photo-1533709752211-118fcaf03312"
class="image-loading__placeholder class"
height="200"
role="presentation"
@@ -12,7 +12,6 @@ exports[`components/MarkdownImage should match snapshot 1`] = `
class="image-loading__container"
>
<img
aria-label="file thumbnail"
class="image-loading__placeholder markdown-inline-img markdown-inline-img--loading"
height="90"
role="presentation"
@@ -168,7 +167,6 @@ exports[`components/MarkdownImage should match snapshot for broken link 1`] = `
class="image-loading__container"
>
<img
aria-label="file thumbnail"
class="image-loading__placeholder markdown-inline-img markdown-inline-img--loading"
height="10"
role="presentation"
@@ -61,7 +61,6 @@ exports[`components/SingleImageView permalink preview should render with permali
</div>
</div>
<img
aria-label="file thumbnail name"
class="image-loading__placeholder image-permalink"
height="200"
role="presentation"
@@ -182,7 +181,6 @@ exports[`components/SingleImageView should match snapshot 1`] = `
</div>
</div>
<img
aria-label="file thumbnail name"
class="image-loading__placeholder"
height="200"
role="presentation"
@@ -384,7 +382,6 @@ exports[`components/SingleImageView should match snapshot, SVG image 1`] = `
</div>
</div>
<img
aria-label="file thumbnail name_svg"
class="image-loading__placeholder"
height="200"
role="presentation"
@@ -408,11 +408,6 @@ export class SizeAwareImage extends React.PureComponent<Props, State> {
} = this.props;
const renderPlaceholderOnly = this.props.renderPlaceholderOnly ?? false;
let ariaLabelImage = this.props.intl.formatMessage({id: 'file_attachment.thumbnail', defaultMessage: 'file thumbnail'});
if (fileInfo) {
ariaLabelImage += ` ${fileInfo.name}`.toLowerCase();
}
let fallback;
if (this.dimensionsAvailable(dimensions) && (!this.state.loaded || renderPlaceholderOnly)) {
@@ -432,7 +427,6 @@ export class SizeAwareImage extends React.PureComponent<Props, State> {
{this.renderImageLoaderIfNeeded()}
<img
role='presentation'
aria-label={ariaLabelImage}
className={classNames('image-loading__placeholder', this.props.className)}
src={fallbackSrc}
height={height}