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, "= 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, "= 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 }