From 1a8c4eda1409eb65dbc9bf1a7eb7c2500c0f2cec Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Sat, 22 Aug 2026 07:58:58 -0700 Subject: [PATCH] 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. --- internal/web/helpers_test.go | 16 +++++++++++ internal/web/profile.go | 55 ++++++++++++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/internal/web/helpers_test.go b/internal/web/helpers_test.go index 4c95805..434cc61 100644 --- a/internal/web/helpers_test.go +++ b/internal/web/helpers_test.go @@ -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 { 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) @@ -54,10 +58,12 @@ func TestPrepareAvatar(t *testing.T) { }{ {name: "png", in: pngBuf.Bytes(), max: 2 << 20, wantExt: ".png"}, {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: "invalid", in: []byte("not-an-image"), max: 2 << 20, wantErr: "unsupported"}, {name: "oversized", in: oversized, max: 2 << 20, wantErr: "too large"}, {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 { t.Run(tc.name, func(t *testing.T) { @@ -77,6 +83,16 @@ func TestPrepareAvatar(t *testing.T) { if len(body) == 0 || 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) + } }) } } diff --git a/internal/web/profile.go b/internal/web/profile.go index 536c5ca..9a775f5 100644 --- a/internal/web/profile.go +++ b/internal/web/profile.go @@ -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