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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+49
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user