fix(artwork): reject decompression-bomb dimensions before decoding

This commit is contained in:
Deluan 2026-07-22 14:02:48 -04:00
parent b3526c0fba
commit d6434b9929
2 changed files with 31 additions and 0 deletions

View File

@ -38,6 +38,10 @@ const thumbnailSize = 128
// point at an arbitrarily large endpoint, and 20MB is generous for any real cover.
const maxImageBytes = 20 << 20
// maxImagePixels caps declared dimensions: a tiny compressed file can declare a
// huge canvas that image.Decode would expand into gigabytes (decompression bomb).
const maxImagePixels = 64 << 20
// workerDeps are the collaborators processItem needs; extGate is set by NewWorker in
// production and nil only in tests, where resolveItem falls back to a plain passthrough.
type workerDeps struct {
@ -147,6 +151,9 @@ func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwor
if err != nil {
return nil, fmt.Errorf("decode image config: %w", err)
}
if int64(cfg.Width)*int64(cfg.Height) > maxImagePixels {
return nil, fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels)
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decode image: %w", err)

View File

@ -2,7 +2,9 @@ package artwork
import (
"context"
"encoding/binary"
"errors"
"hash/crc32"
"net/url"
"os"
"path/filepath"
@ -16,6 +18,21 @@ import (
. "github.com/onsi/gomega"
)
// pngHeaderWithDims builds just a PNG signature + IHDR chunk declaring w×h. DecodeConfig
// reads the header without touching pixel data, so the body can be omitted entirely.
func pngHeaderWithDims(w, h uint32) []byte {
ihdr := make([]byte, 13)
binary.BigEndian.PutUint32(ihdr[0:], w)
binary.BigEndian.PutUint32(ihdr[4:], h)
ihdr[8] = 8 // bit depth
ihdr[9] = 2 // color type: truecolor
chunk := append([]byte("IHDR"), ihdr...)
out := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
out = binary.BigEndian.AppendUint32(out, uint32(len(ihdr)))
out = append(out, chunk...)
return binary.BigEndian.AppendUint32(out, crc32.ChecksumIEEE(chunk))
}
var _ = Describe("processItem", func() {
var (
ctx context.Context
@ -229,6 +246,13 @@ var _ = Describe("processItem", func() {
Expect(err).To(MatchError(model.ErrNotFound))
})
It("decompression bomb: rejects huge declared dimensions before the full decode", func() {
data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap
_, err := decodeArtwork(ctx, "bomb", data)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("dimensions"))
})
It("store write failure: fails without writing state", func() {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},