Files

150 lines
4.6 KiB
Go
Raw Permalink Normal View History

2026-08-01 22:06:14 +02:00
package api
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"testing"
"time"
)
// fuzzWebPVP8L builds a minimal RIFF/WEBP/VP8L container of the given size.
func fuzzWebPVP8L(w, h int) []byte {
buf := make([]byte, 30)
copy(buf[0:4], "RIFF")
binary.LittleEndian.PutUint32(buf[4:8], uint32(len(buf)-8))
copy(buf[8:12], "WEBP")
copy(buf[12:16], "VP8L")
binary.LittleEndian.PutUint32(buf[16:20], uint32(len(buf)-20))
buf[20] = 0x2F
bits := uint32(w-1) | uint32(h-1)<<14
binary.LittleEndian.PutUint32(buf[21:25], bits)
return buf
}
// fuzzWebPVP8 builds a minimal RIFF/WEBP/VP8 (lossy) container.
func fuzzWebPVP8(w, h int) []byte {
buf := make([]byte, 30)
copy(buf[0:4], "RIFF")
copy(buf[8:12], "WEBP")
copy(buf[12:16], "VP8 ")
buf[23], buf[24], buf[25] = 0x9d, 0x01, 0x2a
binary.LittleEndian.PutUint16(buf[26:28], uint16(w)&0x3FFF)
binary.LittleEndian.PutUint16(buf[28:30], uint16(h)&0x3FFF)
return buf
}
// fuzzWebPVP8X builds a minimal RIFF/WEBP/VP8X (extended) container.
func fuzzWebPVP8X(w, h int) []byte {
buf := make([]byte, 30)
copy(buf[0:4], "RIFF")
copy(buf[8:12], "WEBP")
copy(buf[12:16], "VP8X")
w1, h1 := uint32(w-1), uint32(h-1)
buf[24], buf[25], buf[26] = byte(w1), byte(w1>>8), byte(w1>>16)
buf[27], buf[28], buf[29] = byte(h1), byte(h1>>8), byte(h1>>16)
return buf
}
func fuzzPNGBytes(w, h int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
img.Set(0, 0, color.RGBA{R: 255, A: 255})
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
func fuzzGIFBytes(w, h int) []byte {
img := image.NewPaletted(image.Rect(0, 0, w, h), color.Palette{color.Black, color.White})
var buf bytes.Buffer
_ = gif.Encode(&buf, img, nil)
return buf.Bytes()
}
func fuzzJPEGBytes(w, h int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
var buf bytes.Buffer
_ = jpeg.Encode(&buf, img, nil)
return buf.Bytes()
}
// FuzzImageDimensions is the prime crash target: hand-rolled and stdlib
// header parsing over completely untrusted bytes, exactly what a malicious
// emoji/avatar upload delivers. Every corpus entry is run through all four
// supported mime types (not just the one it happens to be valid for) so a
// truncated PNG is also thrown at the JPEG/GIF/WebP paths and vice versa.
//
// The only allowed outcomes are: an error, or a (width, height) that is
// strictly positive. Anything else -- a panic, a hang, or a non-positive
// dimension slipping past as "success" -- is a bug: the caller in
// emoji_handler.go trusts a non-error result enough to compare it against
// maxEmojiDimension without re-validating its sign.
func FuzzImageDimensions(f *testing.F) {
seeds := [][]byte{
nil,
{},
{0},
{0, 0, 0, 0},
[]byte("RIFF"),
[]byte("RIFFxxxxWEBP"),
[]byte("RIFFxxxxWEBPVP8 "),
[]byte("RIFFxxxxWEBPVP8L"),
[]byte("RIFFxxxxWEBPVP8X"),
[]byte("RIFFxxxxWEBPXXXX"),
fuzzPNGBytes(1, 1),
fuzzPNGBytes(128, 128),
fuzzGIFBytes(1, 1),
fuzzGIFBytes(128, 128),
fuzzJPEGBytes(1, 1),
fuzzJPEGBytes(128, 128),
fuzzWebPVP8(100, 80),
fuzzWebPVP8(16383, 16383), // max 14-bit dimension
fuzzWebPVP8L(100, 80),
fuzzWebPVP8L(16384, 16384), // max 14-bit+1 dimension
fuzzWebPVP8X(100, 80),
fuzzWebPVP8X(16777216, 16777216), // max 24-bit+1 dimension
}
// Truncate every seed at each prefix length up to 32 bytes (where every
// format's fixed header lives) and then more coarsely beyond that: the
// classic "header parser unchecked slice index" crasher lives in exactly
// these cuts, but walking every single offset of a 128x128 PNG bloats the
// corpus enough to stall the mutation phase for no extra coverage.
for _, s := range seeds {
f.Add(append([]byte(nil), s...))
for cut := 0; cut < len(s) && cut <= 32; cut++ {
f.Add(append([]byte(nil), s[:cut]...))
}
for cut := 40; cut < len(s); cut += 8 {
f.Add(append([]byte(nil), s[:cut]...))
}
}
mimeTypes := []string{"image/png", "image/jpeg", "image/gif", "image/webp"}
f.Fuzz(func(t *testing.T, raw []byte) {
done := make(chan struct{})
go func() {
defer close(done)
for _, mt := range mimeTypes {
w, h, err := imageDimensions(raw, mt)
if err == nil && (w <= 0 || h <= 0) {
t.Errorf("imageDimensions(%d bytes, %s) returned non-positive size %dx%d with no error", len(raw), mt, w, h)
}
}
w, h, err := webpDimensions(raw)
if err == nil && (w <= 0 || h <= 0) {
t.Errorf("webpDimensions(%d bytes) returned non-positive size %dx%d with no error", len(raw), w, h)
}
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatalf("imageDimensions/webpDimensions hung on %d-byte input: %x", len(raw), raw)
}
})
}