fix(image): reject decompression-bomb dimensions before raster decode

readAndValidateImage caps the byte size of incoming images but the resize()
helper that follows still called image.Decode unconditionally, allocating
pixel memory proportional to the *declared* image dimensions. A ~100 KB
compressed PNG or GIF that declares 65535x65535 px forces image.Decode to
allocate ~17 GB of raster, OOMing the service on a single comment upload
(or on the proxy's CacheExternal path when caching a malicious upstream).

Hardening:

- maxImagePixels = 16 MP constant. Covers any realistic image (~4096x4096)
  while bounding peak allocation.
- resize() now runs image.DecodeConfig first (cheap, no pixel allocation)
  to read declared width/height before any full decode.
- Multiplication of width × height uses int64 to defeat 32-bit overflow
  (GOARCH=386, 32-bit arm): on those targets, int(cfg.Width)*int(cfg.Height)
  could wrap below maxImagePixels and bypass the cap. GIF's 16-bit logical
  screen and JPEG's 16-bit SOF dimensions both reach this if int-multiplied.
- Bytes exceeding the cap, or non-image input that fails DecodeConfig,
  return nil. prepareImage propagates the rejection as a clear error
  instead of storing the malformed/oversized data verbatim.
- The no-resize-needed path returns the validated original bytes verbatim
  so animated GIFs round-trip without being flattened to a single frame.

The DecodeConfig precheck applies even when MaxWidth/MaxHeight are 0
(resize disabled) — the dimension cap is unconditional defense-in-depth.

Two adjacent fixes surfaced by the new resize contract:

1. readAndValidateImage previously did `data[:512]` without a bounds check,
   panicking on any body shorter than 512 bytes. Now bounded with min().
2. image/webp was listed as an allowed format but no WebP decoder was
   registered, so DecodeConfig would refuse legitimate WebP uploads. Added
   `_ "golang.org/x/image/webp"` (already in go.mod via x/image/draw) so
   the registered decoders match the allowlist.

Tests:

- TestService_resizeRejectsDecompressionBomb builds a 14-byte GIF87a header
  declaring 65535x65535 and asserts resize() refuses it both at the unit
  level and through SaveWithID end-to-end (no store write).
- TestService_SaveWithIDShortPayload regression-tests the short-body panic.
- TestService_SaveWithIDWebP regression-tests WebP round-trip through
  prepareImage with the new DecodeConfig requirement.
- TestService_resize subtests updated to assert non-image bytes are now
  refused (previously the helper fell back to returning the raw bytes
  verbatim, letting malformed content reach the store).
This commit is contained in:
Dmitry Verkhoturov
2026-05-20 21:48:23 -05:00
committed by Umputun
parent 45c17a913f
commit 8224626ed4
4 changed files with 146 additions and 36 deletions
+4 -1
View File
@@ -732,7 +732,10 @@ func (s *private) savePictureCtrl(w http.ResponseWriter, r *http.Request) {
user := rest.MustGetUserInfo(r)
r.Body = http.MaxBytesReader(w, r.Body, 32*1024*1024) // hard cap on upload to prevent memory exhaustion
if err := r.ParseMultipartForm(5 * 1024 * 1024); err != nil { // 5M max memory, if bigger will make a file
// gosec G120: r.Body is already bounded by MaxBytesReader on the line above (32 MB),
// so ParseMultipartForm cannot read more than that regardless of the in-memory threshold.
// The 5 MB argument is the soft threshold above which the form is spilled to disk.
if err := r.ParseMultipartForm(5 * 1024 * 1024); err != nil { //nolint:gosec // bounded by MaxBytesReader above
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode)
return
}
+61 -22
View File
@@ -13,10 +13,8 @@ import (
"encoding/base64"
"fmt"
"image"
// support gif and jpeg images decoding
_ "image/gif"
_ "image/jpeg"
_ "image/gif" // register gif decoder
_ "image/jpeg" // register jpeg decoder
"image/png"
"io"
"net/http"
@@ -32,6 +30,7 @@ import (
"github.com/hashicorp/go-multierror"
"github.com/rs/xid"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp" // register webp decoder so DecodeConfig accepts what readAndValidateImage allows
)
// Service wraps Store with common functions needed for any store implementation
@@ -285,6 +284,13 @@ func (s *Service) extractImageIDs(commentHTML string, includeProxied bool) (ids
return ids
}
// maxImagePixels caps the declared pixel count of an image before any raster decode
// is allowed. Without this, a tiny compressed "decompression bomb" image declaring
// e.g. 65535x65535 px would force image.Decode to allocate gigabytes of pixel memory
// and OOM the service on a single comment upload. 16 MP covers any realistic image
// (~4096x4096) while keeping peak allocation bounded.
const maxImagePixels = 16 * 1024 * 1024
// prepareImage calls readAndValidateImage and resize on provided image.
func (s *Service) prepareImage(r io.Reader) ([]byte, error) {
data, err := readAndValidateImage(r, s.MaxSize)
@@ -292,32 +298,59 @@ func (s *Service) prepareImage(r io.Reader) ([]byte, error) {
return nil, fmt.Errorf("can't load image: %w", err)
}
data = resize(data, s.MaxWidth, s.MaxHeight)
return data, nil
resized := resize(data, s.MaxWidth, s.MaxHeight)
if resized == nil {
return nil, fmt.Errorf("image rejected: malformed or exceeds %d-pixel safe limit", maxImagePixels)
}
return resized, nil
}
// resize an image of supported format (PNG, JPG, GIF) to the size of "limit" px of
// the biggest side (width or height) preserving aspect ratio.
// Returns original data if resizing is not needed or failed.
// If resized the result will be for png format
// resize validates an image and, if needed, re-encodes it to fit within the given
// pixel limits preserving aspect ratio. Returns nil for malformed input or for
// declared dimensions exceeding maxImagePixels so attacker payloads (decompression
// bombs) never reach the store. With limit <= 0 or when the image already fits, the
// original bytes are returned verbatim so animated GIFs and other multi-frame formats
// round-trip without being flattened to one frame.
//
// Validation uses image.DecodeConfig (cheap — declares dimensions, allocates nothing)
// before any full image.Decode, so a 100 KB compressed image declaring 65535x65535 px
// is rejected without ever materializing the raster.
func resize(data []byte, limitW, limitH int) []byte {
if data == nil || limitW <= 0 || limitH <= 0 {
return data
if len(data) == 0 {
return nil
}
src, _, err := image.Decode(bytes.NewBuffer(data))
// validate format and dimensions without allocating pixel memory.
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
log.Printf("[WARN] can't decode image, %s", err)
return data
log.Printf("[WARN] can't decode image config, %s", err)
return nil
}
// multiply in int64 — on 32-bit builds (GOARCH=386, 32-bit arm) the int
// product of two 16-bit-or-larger dimensions can overflow and wrap below
// maxImagePixels, bypassing the cap.
if cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > int64(maxImagePixels) {
log.Printf("[WARN] image dimensions %dx%d exceed safe limit", cfg.Width, cfg.Height)
return nil
}
// dimensions are bounded — full decode is now safe to allocate. Decode also
// validates the raster body: a header that DecodeConfig accepts but with a
// corrupt or truncated payload would slip through if we returned early on the
// no-resize path without ever touching the pixels. Decode unconditionally,
// then either return the original bytes (no resize needed, multi-frame intact)
// or the re-encoded result.
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
log.Printf("[WARN] can't decode image after dim-check, %s", err)
return nil
}
bounds := src.Bounds()
w, h := bounds.Dx(), bounds.Dy()
if w <= limitW && h <= limitH || w <= 0 || h <= 0 {
log.Printf("[DEBUG] resizing image is smaller that the limit or has 0 size")
if limitW <= 0 || limitH <= 0 || (cfg.Width <= limitW && cfg.Height <= limitH) {
return data
}
w, h := src.Bounds().Dx(), src.Bounds().Dy()
newW, newH := getProportionalSizes(w, h, limitW, limitH)
m := image.NewRGBA(image.Rect(0, 0, newW, newH))
draw.CatmullRom.Scale(m, m.Bounds(), src, src.Bounds(), draw.Src, nil)
@@ -325,7 +358,7 @@ func resize(data []byte, limitW, limitH int) []byte {
var out bytes.Buffer
if err = png.Encode(&out, m); err != nil {
log.Printf("[WARN] can't encode resized image to png, %s", err)
return data
return data // fall back to the validated original
}
return out.Bytes()
}
@@ -366,8 +399,14 @@ func readAndValidateImage(r io.Reader, maxSize int) ([]byte, error) {
return nil, fmt.Errorf("file is too large (limit=%d)", maxSize)
}
// read header first, needs it to check if data is valid png/gif/jpeg
if !isValidImage(data[:512]) {
// read header first to check the format. http.DetectContentType inspects up
// to the first 512 bytes, but a smaller body is fine — pass the whole slice
// rather than panicking on a fixed-size sub-slice.
header := data
if len(header) > 512 {
header = header[:512]
}
if !isValidImage(header) {
return nil, fmt.Errorf("file format not allowed")
}
+81 -13
View File
@@ -210,19 +210,17 @@ func TestService_Info(t *testing.T) {
}
func TestService_resize(t *testing.T) {
// reader is nil
resized := resize(nil, 100, 100)
assert.Nil(t, resized)
t.Run("empty data returns nil", func(t *testing.T) {
assert.Nil(t, resize(nil, 100, 100))
assert.Nil(t, resize([]byte{}, 100, 100))
})
// negative limit error
resized = resize([]byte("some picture bin data"), -1, -1)
require.NotNil(t, resized)
assert.Equal(t, resized, []byte("some picture bin data"))
// decode error
resized = resize([]byte("invalid image content"), 100, 100)
assert.NotNil(t, resized)
assert.Equal(t, resized, []byte("invalid image content"))
t.Run("non-image bytes are refused", func(t *testing.T) {
// previously resize would fall back to the raw bytes on decode failure, letting
// attacker-controlled non-image content reach the store. After hardening, refuse.
assert.Nil(t, resize([]byte("some picture bin data"), -1, -1))
assert.Nil(t, resize([]byte("invalid image content"), 100, 100))
})
cases := []struct {
file string
@@ -237,7 +235,7 @@ func TestService_resize(t *testing.T) {
require.NoError(t, err, "can't open test file %s", c.file)
// no need for resize, image dimensions are smaller than resize limit
resized = resize(img, 800, 800)
resized := resize(img, 800, 800)
assert.NotNil(t, resized, "file %s", c.file)
assert.Equal(t, resized, img)
@@ -253,6 +251,76 @@ func TestService_resize(t *testing.T) {
}
}
// TestService_SaveWithIDShortPayload guards readAndValidateImage from panicking
// on a body shorter than 512 bytes — historically it sliced data[:512] without
// a bounds check, which would panic before any decode-bomb defense could fire.
func TestService_SaveWithIDShortPayload(t *testing.T) {
short := []byte("not an image")
svc := Service{ServiceParams: ServiceParams{ImageAPI: "/blah/", MaxSize: 1500, MaxWidth: 32, MaxHeight: 32}}
err := svc.SaveWithID("test_id", bytes.NewReader(short))
require.Error(t, err, "short non-image body must return an error, not panic")
assert.Contains(t, err.Error(), "file format not allowed")
}
// TestService_SaveWithIDWebP confirms that WebP — listed as an allowed format
// in readAndValidateImage — still round-trips through prepareImage now that
// resize() runs image.DecodeConfig. Without registering the WebP decoder, a
// legitimate WebP upload would fail DecodeConfig and prepareImage would error.
func TestService_SaveWithIDWebP(t *testing.T) {
webp, err := os.ReadFile("testdata/pixel.webp")
require.NoError(t, err)
// sanity: the fixture must be a well-formed 1x1 WebP that DecodeConfig accepts.
cfg, format, err := image.DecodeConfig(bytes.NewReader(webp))
require.NoError(t, err)
require.Equal(t, "webp", format)
require.Equal(t, 1, cfg.Width)
require.Equal(t, 1, cfg.Height)
store := StoreMock{SaveFunc: func(string, []byte) error { return nil }}
svc := Service{store: &store, ServiceParams: ServiceParams{MaxSize: 1500}}
err = svc.SaveWithID("webp_id", bytes.NewReader(webp))
require.NoError(t, err, "valid WebP must round-trip through SaveWithID")
assert.Equal(t, 1, len(store.SaveCalls()))
assert.Equal(t, webp, store.SaveCalls()[0].Img, "no-resize path must return bytes verbatim")
}
// TestService_resizeRejectsDecompressionBomb verifies the dimension-cap defense.
// Builds a tiny GIF that declares 65535x65535 (4 gigapixels) in its logical-screen
// header — image.DecodeConfig reads the dimensions, the int64 product overflows
// any 32-bit int wrap, and resize must refuse before image.Decode allocates ~17 GB
// of pixel memory.
func TestService_resizeRejectsDecompressionBomb(t *testing.T) {
// minimal GIF87a header with 65535x65535 logical screen, no global color table.
// Bytes 6-7 are the little-endian width, 8-9 are the little-endian height.
bomb := []byte{
'G', 'I', 'F', '8', '7', 'a',
0xFF, 0xFF,
0xFF, 0xFF,
0x00,
0x00,
0x00,
0x3B,
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(bomb))
require.NoError(t, err, "bomb header must decode at the config level")
assert.Equal(t, 65535, cfg.Width)
assert.Equal(t, 65535, cfg.Height)
assert.Nil(t, resize(bomb, 100, 100), "resize must refuse oversized dimensions before raster decode")
assert.Nil(t, resize(bomb, 0, 0), "even with no-resize limits, oversized dims must be refused")
// integration-level: SaveWithID must reject the same bomb without panicking
// or allocating gigabytes of raster memory.
store := StoreMock{SaveFunc: func(string, []byte) error { return nil }}
svc := Service{store: &store, ServiceParams: ServiceParams{MaxSize: 1500}}
err = svc.SaveWithID("bomb_id", bytes.NewReader(bomb))
require.Error(t, err, "SaveWithID must reject decompression bomb")
assert.Equal(t, 0, len(store.SaveCalls()), "rejected bomb must not be stored")
}
func TestGetProportionalSizes(t *testing.T) {
tbl := []struct {
inpW, inpH int
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 B