feat: validate image type on server-side upload

Ultraworked with Sisyphus (https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-31 10:55:09 +01:00
co-authored by Sisyphus
parent 965b8b2794
commit 453fd0dc1d
2 changed files with 63 additions and 22 deletions
+29 -22
View File
@@ -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"
+34
View File
@@ -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)")
}