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) <noreply@anthropic.com>
This commit is contained in:
yushen 2026-03-31 14:55:27 +08:00
parent 29a80e057e
commit edf4c00c08
4 changed files with 54 additions and 26 deletions

View file

@ -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

View file

@ -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())

View file

@ -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")

View file

@ -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)