From edf4c00c08727ca8f2bbd37797bb1f11d85aa652 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 14:55:27 +0800 Subject: [PATCH] fix(upload): add file type/size validation, Content-Disposition header - Add content type allowlist (images, PDF, text, video, audio, zip) - Enforce 10 MB upload limit via http.MaxBytesReader - Set Content-Disposition on S3 objects for proper download filenames - Remove unused CloudFrontSigner.Domain() method Co-Authored-By: Claude Opus 4.6 (1M context) --- server/go.mod | 12 ++++---- server/internal/auth/cloudfront.go | 5 ---- server/internal/handler/file.go | 47 +++++++++++++++++++++++++----- server/internal/storage/s3.go | 16 +++++----- 4 files changed, 54 insertions(+), 26 deletions(-) diff --git a/server/go.mod b/server/go.mod index 2cdbd6d4..30725711 100644 --- a/server/go.mod +++ b/server/go.mod @@ -3,20 +3,23 @@ module github.com/multica-ai/multica/server go 1.26.1 require ( + github.com/aws/aws-sdk-go-v2 v1.41.5 + github.com/aws/aws-sdk-go-v2/config v1.32.13 + github.com/aws/aws-sdk-go-v2/credentials v1.19.13 + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.8.0 + github.com/lmittmann/tint v1.1.3 github.com/resend/resend-go/v2 v2.28.0 github.com/spf13/cobra v1.10.2 ) require ( - github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.13 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect @@ -26,8 +29,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect - github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect @@ -37,7 +38,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/lmittmann/tint v1.1.3 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/server/internal/auth/cloudfront.go b/server/internal/auth/cloudfront.go index 03396aac..ee255378 100644 --- a/server/internal/auth/cloudfront.go +++ b/server/internal/auth/cloudfront.go @@ -137,11 +137,6 @@ func parseRSAPrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { return rsaKey, nil } -// Domain returns the CDN domain (e.g. "static.multica.ai"). -func (s *CloudFrontSigner) Domain() string { - return s.domain -} - // SignedCookies generates the three CloudFront signed cookies with the given expiry. func (s *CloudFrontSigner) SignedCookies(expiry time.Time) []*http.Cookie { policy := fmt.Sprintf(`{"Statement":[{"Resource":"https://%s/*","Condition":{"DateLessThan":{"AWS:EpochTime":%d}}}]}`, s.domain, expiry.Unix()) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index e47c7423..ab7882ee 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -8,16 +8,46 @@ import ( "log/slog" "net/http" "path" + "strings" ) +const maxUploadSize = 10 << 20 // 10 MB + +// Allowed MIME type prefixes and exact types for uploads. +var allowedContentTypes = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/gif": true, + "image/webp": true, + "image/svg+xml": true, + "application/pdf": true, + "text/plain": true, + "text/csv": true, + "application/json": true, + "video/mp4": true, + "video/webm": true, + "audio/mpeg": true, + "audio/wav": true, + "application/zip": true, +} + +func isContentTypeAllowed(ct string) bool { + // Normalize: take only the media type, strip parameters like charset. + ct = strings.TrimSpace(strings.SplitN(ct, ";", 2)[0]) + ct = strings.ToLower(ct) + return allowedContentTypes[ct] +} + func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { if h.Storage == nil { writeError(w, http.StatusServiceUnavailable, "file upload not configured") return } - if err := r.ParseMultipartForm(32 << 20); err != nil { - writeError(w, http.StatusBadRequest, "invalid multipart form") + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) + + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + writeError(w, http.StatusBadRequest, "file too large or invalid multipart form") return } defer r.MultipartForm.RemoveAll() @@ -29,9 +59,15 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { } defer file.Close() + contentType := header.Header.Get("Content-Type") + if !isContentTypeAllowed(contentType) { + writeError(w, http.StatusBadRequest, fmt.Sprintf("file type not allowed: %s", contentType)) + return + } + data, err := io.ReadAll(file) if err != nil { - writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to read file: %v", err)) + writeError(w, http.StatusBadRequest, "failed to read file") return } @@ -43,10 +79,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { } key := hex.EncodeToString(b) + path.Ext(header.Filename) - contentType := header.Header.Get("Content-Type") - link, err := h.Storage.Upload(r.Context(), key, data, contentType, map[string]string{ - "filename": header.Filename, - }) + link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename) if err != nil { slog.Error("file upload failed", "error", err) writeError(w, http.StatusInternalServerError, "upload failed") diff --git a/server/internal/storage/s3.go b/server/internal/storage/s3.go index f839222f..3f03ea86 100644 --- a/server/internal/storage/s3.go +++ b/server/internal/storage/s3.go @@ -67,15 +67,15 @@ func NewS3StorageFromEnv() *S3Storage { } } -func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, metadata map[string]string) (string, error) { +func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) { _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(key), - Body: bytes.NewReader(data), - ContentType: aws.String(contentType), - CacheControl: aws.String("max-age=432000,public"), - StorageClass: types.StorageClassIntelligentTiering, - Metadata: metadata, + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(data), + ContentType: aws.String(contentType), + ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, filename)), + CacheControl: aws.String("max-age=432000,public"), + StorageClass: types.StorageClassIntelligentTiering, }) if err != nil { return "", fmt.Errorf("s3 PutObject: %w", err)