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 {
|
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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user