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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 02:50:23 +00:00
committed by weichen
co-authored by Cursor
parent 5856585d1d
commit 6bc238c665
6 changed files with 105 additions and 20 deletions
+10 -2
View File
@@ -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)
+1 -1
View File
@@ -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 {
+55 -3
View File
@@ -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, "<!doctype") ||
@@ -96,7 +144,7 @@ func assertExtensionMatchesContent(ext string, data []byte, detected string) err
if !bytesHasPrefix(data, []byte("%PDF")) {
return fmt.Errorf("pdf magic mismatch")
}
case ".docx", ".xlsx":
case ".docx", ".xlsx", ".pptx":
if !bytesHasPrefix(data, []byte("PK\x03\x04")) {
return fmt.Errorf("office document magic mismatch")
}
@@ -121,8 +169,12 @@ func mimeCompatible(declared, expected, detected string) bool {
if strings.HasPrefix(detected, "text/") && strings.HasPrefix(expected, "text/") {
return true
}
if detected == "application/zip" && (expected == allowedMaterialExt[".docx"] || expected == allowedMaterialExt[".xlsx"]) {
return true
if detected == "application/zip" {
switch expected {
case allowedMaterialExt[".docx"], allowedMaterialExt[".xlsx"],
allowedTemplateExt[".docx"], allowedTemplateExt[".xlsx"], allowedTemplateExt[".pptx"]:
return true
}
}
if detected == "application/octet-stream" {
return declared == expected || declared == "application/octet-stream"
+32
View File
@@ -42,3 +42,35 @@ func TestValidateProjectMaterial_AcceptsPDF(t *testing.T) {
t.Fatalf("pdf: %v", err)
}
}
func TestValidateTemplate_AcceptsMarkdown(t *testing.T) {
mime, err := ValidateTemplate("brief.md", "text/markdown", []byte("# Title\n\nBody"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if mime != "text/markdown" {
t.Fatalf("mime=%s", mime)
}
}
func TestValidateTemplate_RejectsHTML(t *testing.T) {
data := []byte("<html><script>alert(1)</script></html>")
_, 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)
}
}
+1
View File
@@ -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")
)
+6 -14
View File
@@ -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)