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:
committed by
weichen
co-authored by
Cursor
parent
5856585d1d
commit
6bc238c665
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user