bmp: reject input with invalid palette index

Do not decode a paletted BMP with an out-of-range
palette reference. Avoids a panic when accessing
pixels in the invalid image.

Fixes golang/go#79576
Fixes CVE-2026-42500

Change-Id: I343deae3a777e91fd4003f04eeda929e6a6a6964
Reviewed-on: https://go-review.googlesource.com/c/image/+/781500
Auto-Submit: Gopher Robot <gobot@golang.org>
Reviewed-by: Roland Shoemaker <roland@golang.org>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Neal Patel <nealpatel@google.com>
diff --git a/bmp/reader.go b/bmp/reader.go
index fa9083d..b8af550 100644
--- a/bmp/reader.go
+++ b/bmp/reader.go
@@ -18,6 +18,8 @@
 // feature.
 var ErrUnsupported = errors.New("bmp: unsupported BMP image")
 
+var errInvalidPaletteIndex = errors.New("bmp: invalid palette index")
+
 func readUint16(b []byte) uint16 {
 	return uint16(b[0]) | uint16(b[1])<<8
 }
@@ -29,7 +31,8 @@
 // decodePaletted reads a 1, 2, 4 or 8 bit-per-pixel BMP image from r.
 // If topDown is false, the image rows will be read bottom-up.
 func decodePaletted(r io.Reader, c image.Config, topDown bool, bpp int) (image.Image, error) {
-	paletted := image.NewPaletted(image.Rect(0, 0, c.Width, c.Height), c.ColorModel.(color.Palette))
+	palette := c.ColorModel.(color.Palette)
+	paletted := image.NewPaletted(image.Rect(0, 0, c.Width, c.Height), palette)
 	if c.Width == 0 || c.Height == 0 {
 		return paletted, nil
 	}
@@ -51,7 +54,11 @@
 		byteIndex, bitIndex, mask := 0, 8, byte((1<<bpp)-1)
 		for pixIndex := 0; pixIndex < c.Width; pixIndex++ {
 			bitIndex -= bpp
-			p[pixIndex] = (b[byteIndex]) >> bitIndex & mask
+			paletteIndex := (b[byteIndex]) >> bitIndex & mask
+			if int(paletteIndex) >= len(palette) {
+				return nil, errInvalidPaletteIndex
+			}
+			p[pixIndex] = paletteIndex
 			if bitIndex == 0 {
 				byteIndex++
 				bitIndex = 8
diff --git a/bmp/reader_test.go b/bmp/reader_test.go
index 29a7f71..eee0599 100644
--- a/bmp/reader_test.go
+++ b/bmp/reader_test.go
@@ -6,6 +6,7 @@
 
 import (
 	"bytes"
+	"encoding/binary"
 	"fmt"
 	"image"
 	"io"
@@ -83,6 +84,67 @@
 	}
 }
 
+func TestDecodeConstructed(t *testing.T) {
+	for _, tc := range []struct {
+		name    string
+		b       []byte
+		wantErr error
+	}{{
+		name: "1x1 paletted",
+		b: bmpBuilder{
+			width:        1,
+			height:       1,
+			planes:       1,
+			bitsPerPixel: 1,
+			colorsUsed:   2,
+			colorTable: []colorTableEntry{
+				{0, 0, 0},
+				{0xff, 0xff, 0xff},
+			},
+			data: []byte{0, 0, 0, 0},
+		}.Bytes(),
+		wantErr: nil, // successful base case
+	}, {
+		name: "1x1 rgb",
+		b: bmpBuilder{
+			width:        1,
+			height:       1,
+			planes:       1,
+			bitsPerPixel: 24,
+			data: []byte{
+				0, 0, 0, 0,
+			},
+		}.Bytes(),
+		wantErr: nil, // successful base case
+	}, {
+		name: "invalid palette index",
+		b: bmpBuilder{
+			width:        1,
+			height:       1,
+			planes:       1,
+			bitsPerPixel: 8,
+			colorsUsed:   2,
+			colorTable: []colorTableEntry{
+				{0, 0, 0},
+				{0xff, 0xff, 0xff},
+			},
+			data: []byte{
+				2, 0, 0, 0, // index 2
+			},
+		}.Bytes(),
+		wantErr: errInvalidPaletteIndex,
+	}} {
+		img, _, err := image.Decode(bytes.NewReader(tc.b))
+		if err != tc.wantErr {
+			t.Errorf("%v: Decode error %v; want %v", tc.name, err, tc.wantErr)
+		}
+		if err != nil {
+			continue
+		}
+		_ = img.At(0, 0) // try accessing a pixel
+	}
+}
+
 // TestEOF tests that decoding a BMP image returns io.ErrUnexpectedEOF
 // when there are no headers or data is empty
 func TestEOF(t *testing.T) {
@@ -91,3 +153,49 @@
 		t.Errorf("Error should be io.ErrUnexpectedEOF on nil but got %v", err)
 	}
 }
+
+type bmpBuilder struct {
+	width           int32
+	height          int32
+	planes          uint16
+	bitsPerPixel    uint16
+	compression     uint32
+	imageSize       uint32
+	xppm            uint32
+	yppm            uint32
+	colorsUsed      uint32
+	colorsImportant uint32
+	colorTable      []colorTableEntry
+	data            []byte
+}
+
+type colorTableEntry struct {
+	r, g, b byte
+}
+
+func (b bmpBuilder) Bytes() []byte {
+	buf := []byte{
+		0x42, 0x4d, // 'BM'
+		0x00, 0x00, 0x00, 0x00, // file size
+		0x00, 0x00, 0x00, 0x00, // reserved
+		0x00, 0x00, 0x00, 0x00, // data offset
+		0x28, 0x00, 0x00, 0x00, // header size (40)
+	}
+	buf = binary.LittleEndian.AppendUint32(buf, uint32(b.width))
+	buf = binary.LittleEndian.AppendUint32(buf, uint32(b.height))
+	buf = binary.LittleEndian.AppendUint16(buf, b.planes)
+	buf = binary.LittleEndian.AppendUint16(buf, b.bitsPerPixel)
+	buf = binary.LittleEndian.AppendUint32(buf, b.compression)
+	buf = binary.LittleEndian.AppendUint32(buf, b.imageSize)
+	buf = binary.LittleEndian.AppendUint32(buf, b.xppm)
+	buf = binary.LittleEndian.AppendUint32(buf, b.yppm)
+	buf = binary.LittleEndian.AppendUint32(buf, b.colorsUsed)
+	buf = binary.LittleEndian.AppendUint32(buf, b.colorsImportant)
+	for _, e := range b.colorTable {
+		buf = append(buf, e.r, e.g, e.b, 0)
+	}
+	binary.LittleEndian.PutUint32(buf[10:], uint32(len(buf))) // data offset
+	buf = append(buf, b.data...)
+	binary.LittleEndian.PutUint32(buf[2:], uint32(len(buf))) // file size
+	return buf
+}