Tighten avatar decode and encoded size limits.

Reject images over 1024px before pixel decode, resize down to 512 for storage, and cap re-encoded output at the upload byte limit.
This commit is contained in:
2026-08-22 07:58:58 -07:00
parent f4cec32afb
commit 1a8c4eda14
2 changed files with 65 additions and 6 deletions
+16
View File
@@ -42,6 +42,10 @@ func TestPrepareAvatar(t *testing.T) {
if err := jpeg.Encode(&jpegBuf, image.NewRGBA(image.Rect(0, 0, 2, 2)), &jpeg.Options{Quality: 90}); err != nil { if err := jpeg.Encode(&jpegBuf, image.NewRGBA(image.Rect(0, 0, 2, 2)), &jpeg.Options{Quality: 90}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
var largePNG bytes.Buffer
if err := png.Encode(&largePNG, image.NewRGBA(image.Rect(0, 0, 800, 600))); err != nil {
t.Fatal(err)
}
oversized := bytes.Repeat([]byte{0x89}, (2<<20)+2) oversized := bytes.Repeat([]byte{0x89}, (2<<20)+2)
@@ -54,10 +58,12 @@ func TestPrepareAvatar(t *testing.T) {
}{ }{
{name: "png", in: pngBuf.Bytes(), max: 2 << 20, wantExt: ".png"}, {name: "png", in: pngBuf.Bytes(), max: 2 << 20, wantExt: ".png"},
{name: "jpeg", in: jpegBuf.Bytes(), max: 2 << 20, wantExt: ".jpg"}, {name: "jpeg", in: jpegBuf.Bytes(), max: 2 << 20, wantExt: ".jpg"},
{name: "resize large", in: largePNG.Bytes(), max: 2 << 20, wantExt: ".png"},
{name: "empty", in: nil, max: 2 << 20, wantErr: "empty"}, {name: "empty", in: nil, max: 2 << 20, wantErr: "empty"},
{name: "invalid", in: []byte("not-an-image"), max: 2 << 20, wantErr: "unsupported"}, {name: "invalid", in: []byte("not-an-image"), max: 2 << 20, wantErr: "unsupported"},
{name: "oversized", in: oversized, max: 2 << 20, wantErr: "too large"}, {name: "oversized", in: oversized, max: 2 << 20, wantErr: "too large"},
{name: "huge dims", in: pngWithDims(100000, 100000), max: 2 << 20, wantErr: "dimensions"}, {name: "huge dims", in: pngWithDims(100000, 100000), max: 2 << 20, wantErr: "dimensions"},
{name: "over decode cap", in: pngWithDims(2048, 2048), max: 2 << 20, wantErr: "dimensions"},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -77,6 +83,16 @@ func TestPrepareAvatar(t *testing.T) {
if len(body) == 0 || ct == "" { if len(body) == 0 || ct == "" {
t.Fatalf("empty output body/ct") t.Fatalf("empty output body/ct")
} }
if int64(len(body)) > tc.max {
t.Fatalf("encoded size %d exceeds max %d", len(body), tc.max)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
if cfg.Width > 512 || cfg.Height > 512 {
t.Fatalf("avatar dims %dx%d exceed 512", cfg.Width, cfg.Height)
}
}) })
} }
} }
+49 -6
View File
@@ -12,6 +12,7 @@ import (
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
"plumber/internal/blob" "plumber/internal/blob"
@@ -107,8 +108,8 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/profile", http.StatusSeeOther) http.Redirect(w, r, "/profile", http.StatusSeeOther)
} }
// prepareAvatar reads at most maxBytes, sniffs/decodes the image, and re-encodes // prepareAvatar reads at most maxBytes, sniffs/decodes the image, resizes to a
// it so only valid image bytes are stored publicly. // small avatar, and re-encodes so only bounded valid image bytes are stored.
func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType string, err error) { func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType string, err error) {
limited := io.LimitReader(r, maxBytes+1) limited := io.LimitReader(r, maxBytes+1)
raw, err := io.ReadAll(limited) raw, err := io.ReadAll(limited)
@@ -135,9 +136,10 @@ func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType s
if err != nil { if err != nil {
return nil, "", "", err return nil, "", "", err
} }
const maxDim = 4096 // Cap decoded size before allocating pixel buffers (~4 MiB RGBA at 1024²).
const maxPixels = 4096 * 4096 const maxDecodeDim = 1024
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDim || cfg.Height > maxDim { const maxPixels = maxDecodeDim * maxDecodeDim
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDecodeDim || cfg.Height > maxDecodeDim {
return nil, "", "", fmt.Errorf("image dimensions out of range") return nil, "", "", fmt.Errorf("image dimensions out of range")
} }
if int64(cfg.Width)*int64(cfg.Height) > maxPixels { if int64(cfg.Width)*int64(cfg.Height) > maxPixels {
@@ -152,23 +154,64 @@ func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType s
decodedFormat = format decodedFormat = format
} }
const maxAvatarDim = 512
img = fitAvatar(img, maxAvatarDim)
var out bytes.Buffer var out bytes.Buffer
switch decodedFormat { switch decodedFormat {
case "jpeg": case "jpeg":
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 90}); err != nil { if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 85}); err != nil {
return nil, "", "", err return nil, "", "", err
} }
if int64(out.Len()) > maxBytes {
return nil, "", "", fmt.Errorf("encoded avatar too large")
}
return out.Bytes(), ".jpg", "image/jpeg", nil return out.Bytes(), ".jpg", "image/jpeg", nil
case "png", "webp": case "png", "webp":
if err := png.Encode(&out, img); err != nil { if err := png.Encode(&out, img); err != nil {
return nil, "", "", err return nil, "", "", err
} }
if int64(out.Len()) > maxBytes {
// Fall back to JPEG when PNG balloons past the upload cap.
out.Reset()
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 85}); err != nil {
return nil, "", "", err
}
if int64(out.Len()) > maxBytes {
return nil, "", "", fmt.Errorf("encoded avatar too large")
}
return out.Bytes(), ".jpg", "image/jpeg", nil
}
return out.Bytes(), ".png", "image/png", nil return out.Bytes(), ".png", "image/png", nil
default: default:
return nil, "", "", fmt.Errorf("unsupported format %s", decodedFormat) return nil, "", "", fmt.Errorf("unsupported format %s", decodedFormat)
} }
} }
// fitAvatar scales img down so both sides are at most maxDim.
func fitAvatar(img image.Image, maxDim int) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxDim && h <= maxDim {
return img
}
scale := float64(maxDim) / float64(w)
if float64(h)*scale > float64(maxDim) {
scale = float64(maxDim) / float64(h)
}
nw := int(float64(w) * scale)
nh := int(float64(h) * scale)
if nw < 1 {
nw = 1
}
if nh < 1 {
nh = 1
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
return dst
}
func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal string) { func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal string) {
var ( var (
questions []store.RankedQuestion questions []store.RankedQuestion