From 6bc238c66527a255713113f9a9a9888b577b9dc7 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 08:59:17 +0000 Subject: [PATCH] feat(upload): reuse MIME validator for workflow template uploads. Centralize template extension, magic-byte, and HTML/script rejection in upload.ValidateTemplate so admin template POST matches project asset hardening. Co-authored-by: Cursor --- internal/server/admin_template_handlers.go | 12 ++++- internal/server/body_limit_middleware.go | 2 +- internal/upload/validate.go | 58 ++++++++++++++++++++-- internal/upload/validate_test.go | 32 ++++++++++++ internal/workflowdef/errors.go | 1 + internal/workflowdef/service.go | 20 +++----- 6 files changed, 105 insertions(+), 20 deletions(-) diff --git a/internal/server/admin_template_handlers.go b/internal/server/admin_template_handlers.go index 81a184f2..c4030c66 100644 --- a/internal/server/admin_template_handlers.go +++ b/internal/server/admin_template_handlers.go @@ -1,6 +1,7 @@ package server import ( + "errors" "io" "net/http" "strings" @@ -23,7 +24,11 @@ func (s *Server) handleAdminTemplates(w http.ResponseWriter, r *http.Request) { } writeJSONResponse(w, http.StatusOK, map[string]any{"templates": assets}) case http.MethodPost: - if err := r.ParseMultipartForm(32 << 20); err != nil { + if err := r.ParseMultipartForm(defaultUploadBodyLimit); err != nil { + if isRequestBodyTooLarge(err) { + http.Error(w, "upload too large", http.StatusRequestEntityTooLarge) + return + } http.Error(w, "invalid multipart", http.StatusBadRequest) return } @@ -49,7 +54,10 @@ func (s *Server) handleAdminTemplates(w http.ResponseWriter, r *http.Request) { tv, err := s.wfDefSvc.UploadTemplate(r.Context(), assetKey, name, data, header.Filename, user.ID) if err != nil { status := http.StatusBadRequest - if err == workflowdef.ErrNotAvailable { + switch { + case errors.Is(err, workflowdef.ErrUploadRejected): + status = http.StatusUnsupportedMediaType + case errors.Is(err, workflowdef.ErrNotAvailable): status = http.StatusServiceUnavailable } http.Error(w, err.Error(), status) diff --git a/internal/server/body_limit_middleware.go b/internal/server/body_limit_middleware.go index 035de865..971e46f9 100644 --- a/internal/server/body_limit_middleware.go +++ b/internal/server/body_limit_middleware.go @@ -29,7 +29,7 @@ func isUploadPath(path string) bool { if strings.HasSuffix(path, "/assets") { return true } - return strings.Contains(path, "/admin/templates") && strings.HasSuffix(path, "/upload") + return strings.Contains(path, "/admin/templates") } func isRequestBodyTooLarge(err error) bool { diff --git a/internal/upload/validate.go b/internal/upload/validate.go index 086e1e16..910b18f2 100644 --- a/internal/upload/validate.go +++ b/internal/upload/validate.go @@ -10,6 +10,14 @@ import ( const maxFilenameRunes = 200 +// 工作流模板上传允许的扩展名 → 期望 MIME。 +var allowedTemplateExt = map[string]string{ + ".md": "text/markdown", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", +} + // 项目材料上传允许的扩展名 → 期望 MIME。 var allowedMaterialExt = map[string]string{ ".txt": "text/plain", @@ -68,6 +76,46 @@ func ValidateProjectMaterial(filename, declaredMIME string, data []byte) (string return expectedMIME, nil } +// ValidateTemplate 校验工作流模板文件名、扩展名白名单与内容魔数/MIME。 +func ValidateTemplate(filename, declaredMIME string, data []byte) (string, error) { + base := filepath.Base(strings.TrimSpace(filename)) + if base == "" || base == "." || base == ".." { + return "", fmt.Errorf("invalid filename") + } + if utf8.RuneCountInString(base) > maxFilenameRunes { + return "", fmt.Errorf("filename too long") + } + ext := strings.ToLower(filepath.Ext(base)) + expectedMIME, ok := allowedTemplateExt[ext] + if !ok { + return "", fmt.Errorf("file type not allowed: %s", ext) + } + if len(data) == 0 { + return "", fmt.Errorf("empty file") + } + if len(data) > 32*1024*1024 { + return "", fmt.Errorf("file too large") + } + if err := rejectDangerousContent(data); err != nil { + return "", err + } + if looksLikeHTML(data) { + return "", fmt.Errorf("html content rejected") + } + detected := http.DetectContentType(data) + if _, blocked := blockedDetectedMIME[detected]; blocked { + return "", fmt.Errorf("dangerous content type detected: %s", detected) + } + if err := assertExtensionMatchesContent(ext, data, detected); err != nil { + return "", err + } + declaredMIME = strings.TrimSpace(strings.Split(declaredMIME, ";")[0]) + if declaredMIME != "" && !mimeCompatible(declaredMIME, expectedMIME, detected) { + return "", fmt.Errorf("declared content type mismatch") + } + return expectedMIME, nil +} + func looksLikeHTML(data []byte) bool { sample := strings.ToLower(strings.TrimSpace(string(data[:min(len(data), 512)]))) return strings.HasPrefix(sample, "") + _, err := ValidateTemplate("evil.md", "text/markdown", data) + if err == nil { + t.Fatal("expected rejection for html content") + } +} + +func TestValidateTemplate_RejectsFakeOfficeDoc(t *testing.T) { + _, err := ValidateTemplate("fake.docx", "", []byte("not a zip")) + if err == nil { + t.Fatal("expected office magic rejection") + } +} + +func TestValidateTemplate_AcceptsOfficeZipMagic(t *testing.T) { + _, err := ValidateTemplate("deck.pptx", "", []byte("PK\x03\x04fake-pptx")) + if err != nil { + t.Fatalf("pptx: %v", err) + } +} diff --git a/internal/workflowdef/errors.go b/internal/workflowdef/errors.go index 9552c34b..4a9f9def 100644 --- a/internal/workflowdef/errors.go +++ b/internal/workflowdef/errors.go @@ -11,4 +11,5 @@ var ( ErrPublishedImmutable = errors.New("workflowdef: published version is immutable") ErrValidationFailed = errors.New("workflowdef: validation failed") ErrTemplateNotReady = errors.New("workflowdef: template version not ready") + ErrUploadRejected = errors.New("workflowdef: upload rejected") ) diff --git a/internal/workflowdef/service.go b/internal/workflowdef/service.go index f4851585..98971a2b 100644 --- a/internal/workflowdef/service.go +++ b/internal/workflowdef/service.go @@ -2,12 +2,14 @@ package workflowdef import ( "context" + "fmt" "path/filepath" "strings" "github.com/jackc/pgx/v5/pgxpool" "chat-agent-system/internal/storage" + "chat-agent-system/internal/upload" ) // Service 工作流定义与模板领域服务。 @@ -90,25 +92,15 @@ func (s *Service) ListTemplateVersions(ctx context.Context, assetKey string) ([] return s.store.ListTemplateVersions(ctx, assetKey) } -var allowedTemplateExt = map[string]string{ - ".md": "text/markdown", - ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", -} - -// UploadTemplate 上传模板文件并创建 template_version(MVP:扩展名白名单后标记 ready)。 +// UploadTemplate 上传模板文件并创建 template_version(扩展名白名单 + MIME/魔数校验后标记 ready)。 func (s *Service) UploadTemplate(ctx context.Context, assetKey, name string, data []byte, fileName string, createdBy int64) (*TemplateVersion, error) { if !s.Available() || s.cas == nil { return nil, ErrNotAvailable } ext := strings.ToLower(filepath.Ext(fileName)) - mime, ok := allowedTemplateExt[ext] - if !ok { - return nil, ErrInvalidInput - } - if len(data) == 0 || len(data) > 32*1024*1024 { - return nil, ErrInvalidInput + mime, err := upload.ValidateTemplate(fileName, "", data) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUploadRejected, err) } ref, err := s.cas.StoreFile(ctx, 0, createdBy, fileName, "template", data, mime)