From 453fd0dc1d5042474b602c4c8dd48a425f45471d Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sun, 31 May 2026 10:55:09 +0100 Subject: [PATCH] feat: validate image type on server-side upload Ultraworked with Sisyphus (https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/portfolio/images.go | 51 ++++++++++++++++------------ backend/internal/images/validate.go | 34 +++++++++++++++++++ 2 files changed, 63 insertions(+), 22 deletions(-) create mode 100644 backend/internal/images/validate.go diff --git a/backend/handlers/portfolio/images.go b/backend/handlers/portfolio/images.go index d1db1f4..e806887 100644 --- a/backend/handlers/portfolio/images.go +++ b/backend/handlers/portfolio/images.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crussell/db" + "crussell/internal/images" "crussell/internal/s3" "crussell/mw" "encoding/json" @@ -43,6 +44,7 @@ func processImage(data []byte, quality int) ([]byte, error) { } // validateInputLength returns an error if input exceeds max length + func validateInputLength(input string) error { if len(input) > MaxInputLength { return fmt.Errorf("input exceeds maximum length of %d characters", MaxInputLength) @@ -339,7 +341,12 @@ func ListFilters(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(key, "filter[") && len(values) > 0 && values[0] != "" { category := strings.Trim(key, "[]") category = strings.TrimPrefix(category, "filter[") - selectedCategories[category] = values[0] + value := values[0] + if len(category)+len(value)+1 > 256 { + http.Error(w, "filter value too long", http.StatusBadRequest) + return + } + selectedCategories[category] = value } } @@ -548,7 +555,7 @@ func UploadImage(w http.ResponseWriter, r *http.Request) { r.ParseMultipartForm(10 << 20) - file, header, err := r.FormFile("file") + file, _, err := r.FormFile("file") if err != nil { log.Printf("Failed to get file: %v", err) http.Error(w, "No file provided", http.StatusBadRequest) @@ -556,7 +563,7 @@ func UploadImage(w http.ResponseWriter, r *http.Request) { } defer file.Close() - thumbFile, thumbHeader, err := r.FormFile("thumbnail") + thumbFile, _, err := r.FormFile("thumbnail") if err != nil { log.Printf("Failed to get thumbnail: %v", err) http.Error(w, "No thumbnail provided", http.StatusBadRequest) @@ -573,30 +580,19 @@ func UploadImage(w http.ResponseWriter, r *http.Request) { } } - // Use nanosecond timestamp for unique keys - timestamp := time.Now().UnixNano() - - // Extract extension from main file - mainExt := ".jpg" - if idx := strings.LastIndex(header.Filename, "."); idx != -1 { - mainExt = strings.ToLower(header.Filename[idx:]) - } - - // Extract extension from thumbnail file - thumbExt := ".jpg" - if idx := strings.LastIndex(thumbHeader.Filename, "."); idx != -1 { - thumbExt = strings.ToLower(thumbHeader.Filename[idx:]) - } - - key := fmt.Sprintf("portfolio/%d%s", timestamp, mainExt) - thumbKey := fmt.Sprintf("portfolio/%d_thumb%s", timestamp, thumbExt) - fileBytes, err := io.ReadAll(file) if err != nil { log.Printf("Failed to read file: %v", err) - http.Error(w, "Failed to read file", http.StatusInternalServerError) + http.Error(w, "Failed to read file", http.StatusBadRequest) return } + + ext, err := images.ValidateImageBytes(fileBytes) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // Images are already processed by frontend (compressed, metadata stripped) // Skip re-encoding - just use the uploaded bytes directly @@ -607,6 +603,17 @@ func UploadImage(w http.ResponseWriter, r *http.Request) { return } + thumbExt, err := images.ValidateImageBytes(thumbBytes) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Use nanosecond timestamp for unique keys + timestamp := time.Now().UnixNano() + key := fmt.Sprintf("portfolio/%d%s", timestamp, ext) + thumbKey := fmt.Sprintf("portfolio/%d_thumb%s", timestamp, thumbExt) + // Thumbnail is already processed by frontend - use as-is bucket := "crussell" diff --git a/backend/internal/images/validate.go b/backend/internal/images/validate.go new file mode 100644 index 0000000..057ac4a --- /dev/null +++ b/backend/internal/images/validate.go @@ -0,0 +1,34 @@ +package images + +import ( + "bytes" + "fmt" +) + +var allowedImageMagic = []struct { + magic []byte + ext string +}{ + {[]byte{0xff, 0xd8, 0xff}, ".jpg"}, + {[]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, ".png"}, + {[]byte{'G', 'I', 'F', '8', '7', 'a'}, ".gif"}, + {[]byte{'G', 'I', 'F', '8', '9', 'a'}, ".gif"}, +} + +// ValidateImageBytes checks that data begins with a known image magic number +// and returns the appropriate file extension. Supports JPEG, PNG, WebP, and GIF. +func ValidateImageBytes(data []byte) (string, error) { + if len(data) < 12 { + return "", fmt.Errorf("file too small to be a valid image") + } + for _, m := range allowedImageMagic { + if bytes.HasPrefix(data, m.magic) { + return m.ext, nil + } + } + // WebP: bytes 0-3 = RIFF, bytes 8-11 = WEBP + if len(data) >= 12 && bytes.Equal(data[0:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")) { + return ".webp", nil + } + return "", fmt.Errorf("file is not a supported image type (jpeg, png, webp, gif)") +}