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
+49 -6
View File
@@ -12,6 +12,7 @@ import (
"strings"
"github.com/google/uuid"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"plumber/internal/blob"
@@ -107,8 +108,8 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/profile", http.StatusSeeOther)
}
// prepareAvatar reads at most maxBytes, sniffs/decodes the image, and re-encodes
// it so only valid image bytes are stored publicly.
// prepareAvatar reads at most maxBytes, sniffs/decodes the image, resizes to a
// 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) {
limited := io.LimitReader(r, maxBytes+1)
raw, err := io.ReadAll(limited)
@@ -135,9 +136,10 @@ func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType s
if err != nil {
return nil, "", "", err
}
const maxDim = 4096
const maxPixels = 4096 * 4096
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDim || cfg.Height > maxDim {
// Cap decoded size before allocating pixel buffers (~4 MiB RGBA at 1024²).
const maxDecodeDim = 1024
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")
}
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
}
const maxAvatarDim = 512
img = fitAvatar(img, maxAvatarDim)
var out bytes.Buffer
switch decodedFormat {
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
}
if int64(out.Len()) > maxBytes {
return nil, "", "", fmt.Errorf("encoded avatar too large")
}
return out.Bytes(), ".jpg", "image/jpeg", nil
case "png", "webp":
if err := png.Encode(&out, img); err != nil {
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
default:
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) {
var (
questions []store.RankedQuestion