Files
Agent-System/internal/upload/validate.go
T
weichenandCursor 6bc238c665 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>
2026-09-13 02:50:23 +00:00

204 lines
6.2 KiB
Go

package upload
import (
"fmt"
"net/http"
"path/filepath"
"strings"
"unicode/utf8"
)
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",
".md": "text/plain",
".csv": "text/plain",
".json": "application/json",
".xml": "application/xml",
".yaml": "text/plain",
".yml": "text/plain",
".log": "text/plain",
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}
var blockedDetectedMIME = map[string]struct{}{
"text/html": {},
"application/javascript": {},
"application/x-javascript": {},
}
// ValidateProjectMaterial 校验文件名、扩展名白名单与内容魔数/MIME。
func ValidateProjectMaterial(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 := allowedMaterialExt[ext]
if !ok {
return "", fmt.Errorf("file type not allowed: %s", ext)
}
if len(data) == 0 {
return "", fmt.Errorf("empty file")
}
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
}
// 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") ||
strings.HasPrefix(sample, "<html") ||
strings.Contains(sample, "<script") ||
strings.Contains(sample, "<iframe")
}
func rejectDangerousContent(data []byte) error {
if len(data) >= 2 && data[0] == 'M' && data[1] == 'Z' {
return fmt.Errorf("executable content rejected")
}
if len(data) >= 4 && data[0] == 0x7f && data[1] == 'E' && data[2] == 'L' && data[3] == 'F' {
return fmt.Errorf("executable content rejected")
}
head := strings.ToLower(string(data[:min(len(data), 256)]))
if strings.HasPrefix(head, "#!/") || strings.Contains(head, "<?php") {
return fmt.Errorf("script content rejected")
}
return nil
}
func assertExtensionMatchesContent(ext string, data []byte, detected string) error {
switch ext {
case ".pdf":
if !bytesHasPrefix(data, []byte("%PDF")) {
return fmt.Errorf("pdf magic mismatch")
}
case ".docx", ".xlsx", ".pptx":
if !bytesHasPrefix(data, []byte("PK\x03\x04")) {
return fmt.Errorf("office document magic mismatch")
}
case ".txt", ".md", ".csv", ".log", ".yaml", ".yml":
if containsNullByte(data[:min(len(data), 8192)]) {
return fmt.Errorf("text file contains binary content")
}
case ".json":
if !strings.HasPrefix(strings.TrimSpace(string(data[:min(len(data), 32)])), "{") &&
!strings.HasPrefix(strings.TrimSpace(string(data[:min(len(data), 32)])), "[") {
return fmt.Errorf("json magic mismatch")
}
}
_ = detected
return nil
}
func mimeCompatible(declared, expected, detected string) bool {
if declared == expected {
return true
}
if strings.HasPrefix(detected, "text/") && strings.HasPrefix(expected, "text/") {
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"
}
return declared == detected
}
func bytesHasPrefix(data, prefix []byte) bool {
return len(data) >= len(prefix) && string(data[:len(prefix)]) == string(prefix)
}
func containsNullByte(data []byte) bool {
for _, b := range data {
if b == 0 {
return true
}
}
return false
}
func min(a, b int) int {
if a < b {
return a
}
return b
}