diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 73a522daf..58def5fc0 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -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) diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 876124bc3..574bb106b 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -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"}},