Skip to content

Commit 63818f5

Browse files
authored
fix: upload zip (#442)
* fix: upload zip * fix: upload zip * fix: upload zip
1 parent d91ac42 commit 63818f5

File tree

2 files changed

+122
-11
lines changed

2 files changed

+122
-11
lines changed

backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/DatasetFileApplicationService.java

Lines changed: 105 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import lombok.extern.slf4j.Slf4j;
3737
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
3838
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
39+
import org.apache.commons.io.FileUtils;
3940
import org.apache.commons.io.IOUtils;
4041
import org.apache.commons.lang3.StringUtils;
4142
import org.springframework.beans.factory.annotation.Autowired;
@@ -53,6 +54,7 @@
5354
import java.nio.file.Files;
5455
import java.nio.file.Path;
5556
import java.nio.file.Paths;
57+
import java.nio.file.StandardCopyOption;
5658
import java.nio.file.attribute.BasicFileAttributes;
5759
import java.time.LocalDateTime;
5860
import java.time.ZoneId;
@@ -364,18 +366,18 @@ public void downloadDatasetFileAsZip(String datasetId, HttpServletResponse respo
364366
}
365367
String datasetPath = dataset.getPath();
366368
Path downloadPath = Paths.get(datasetPath).normalize();
367-
369+
368370
// 检查路径是否存在
369371
if (!Files.exists(downloadPath) || !Files.isDirectory(downloadPath)) {
370372
throw BusinessException.of(DataManagementErrorCode.DATASET_NOT_FOUND);
371373
}
372-
374+
373375
response.setContentType("application/zip");
374376
String zipName = String.format("dataset_%s_%s.zip",
375377
dataset.getName() != null ? dataset.getName().replaceAll("[^a-zA-Z0-9_-]", "_") : "dataset",
376378
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")));
377379
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + zipName + "\"");
378-
380+
379381
try (ZipArchiveOutputStream zos = new ZipArchiveOutputStream(response.getOutputStream())) {
380382
try (Stream<Path> pathStream = Files.walk(downloadPath)) {
381383
pathStream
@@ -442,19 +444,25 @@ public String preUpload(UploadFilesPreRequest chunkUploadRequest, String dataset
442444
if (Objects.isNull(datasetRepository.getById(datasetId))) {
443445
throw BusinessException.of(DataManagementErrorCode.DATASET_NOT_FOUND);
444446
}
445-
447+
446448
// 构建上传路径,如果有 prefix 则追加到路径中
447449
String prefix = Optional.ofNullable(chunkUploadRequest.getPrefix()).orElse("").trim();
448450
prefix = prefix.replace("\\", "/");
449451
while (prefix.startsWith("/")) {
450452
prefix = prefix.substring(1);
451453
}
452-
453-
String uploadPath = datasetBasePath + File.separator + datasetId;
454-
if (!prefix.isEmpty()) {
455-
uploadPath = uploadPath + File.separator + prefix.replace("/", File.separator);
454+
455+
String uploadPath;
456+
// 如果需要解压,上传到全局临时目录以避免覆盖数据集中的同名文件
457+
if (chunkUploadRequest.isHasArchive()) {
458+
uploadPath = datasetBasePath + File.separator + ".temp_upload_" + System.currentTimeMillis();
459+
} else {
460+
uploadPath = datasetBasePath + File.separator + datasetId;
461+
if (!prefix.isEmpty()) {
462+
uploadPath = uploadPath + File.separator + prefix.replace("/", File.separator);
463+
}
456464
}
457-
465+
458466
ChunkUploadPreRequest request = ChunkUploadPreRequest.builder().build();
459467
request.setUploadPath(uploadPath);
460468
request.setTotalFileNum(chunkUploadRequest.getTotalFileNum());
@@ -512,8 +520,81 @@ private void saveFileInfoToDb(FileUploadResult fileUploadResult, String datasetI
512520
private void addFileToDataset(String datasetId, List<FileUploadResult> unpacked) {
513521
Dataset dataset = datasetRepository.getById(datasetId);
514522
dataset.setFiles(datasetFileRepository.findAllByDatasetId(datasetId));
523+
524+
// 收集所有临时目录,在文件处理完后统一删除
525+
Set<String> tempDirsToDelete = new HashSet<>();
526+
515527
for (FileUploadResult file : unpacked) {
516528
File savedFile = file.getSavedFile();
529+
String filePath = savedFile.getPath();
530+
531+
// 如果文件在临时目录(从解压的上传来),移动到数据集目录
532+
if (filePath.contains(".temp_upload_")) {
533+
try {
534+
// 提取临时目录之后的部分(保持相对路径结构)
535+
int tempIndex = filePath.indexOf(".temp_upload_");
536+
537+
// 找到 .temp_upload_{timestamp}/ 之后的内容
538+
// 临时目录格式: /dataset/.temp_upload_{timestamp}/
539+
int afterTimestamp = filePath.indexOf(File.separator, tempIndex + ".temp_upload_".length());
540+
if (afterTimestamp == -1) {
541+
// 没有分隔符,说明文件就在临时目录根下
542+
afterTimestamp = filePath.length();
543+
}
544+
545+
String tempDir = filePath.substring(0, afterTimestamp);
546+
String relativePath = filePath.substring(afterTimestamp);
547+
548+
// 去掉相对路径开头的分隔符
549+
if (relativePath.startsWith(File.separator)) {
550+
relativePath = relativePath.substring(1);
551+
}
552+
553+
// 构建安全的目标路径:防止目录遍历攻击
554+
// 校验 datasetId,防止目录遍历或非法路径片段
555+
if (datasetId.contains("..") || datasetId.contains("/") || datasetId.contains("\\")) {
556+
throw BusinessException.of(CommonErrorCode.PARAM_ERROR, "Invalid datasetId: " + datasetId);
557+
}
558+
559+
// 校验相对路径,防止目录遍历
560+
if (relativePath.contains("..")) {
561+
throw BusinessException.of(CommonErrorCode.PARAM_ERROR, "Invalid relative path: " + relativePath);
562+
}
563+
564+
// 使用 Path API 安全地构建路径
565+
Path datasetBaseDirPath = Paths.get(datasetBasePath).resolve(datasetId).normalize();
566+
Path targetPath;
567+
if (!relativePath.isEmpty()) {
568+
targetPath = datasetBaseDirPath.resolve(relativePath).normalize();
569+
} else {
570+
targetPath = datasetBaseDirPath;
571+
}
572+
573+
// 确保目标路径仍然位于数据集根目录之下
574+
if (!targetPath.startsWith(datasetBaseDirPath)) {
575+
throw BusinessException.of(CommonErrorCode.PARAM_ERROR,
576+
"Path traversal detected: " + relativePath);
577+
}
578+
579+
File targetFile = targetPath.toFile();
580+
// 创建父目录
581+
FileUtils.createParentDirectories(targetFile);
582+
583+
// 移动文件(覆盖已存在的文件)
584+
Files.move(savedFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
585+
log.info("Moved file from temp dir: {} -> {}", savedFile.getPath(), targetPath);
586+
587+
// 收集临时目录,稍后删除
588+
tempDirsToDelete.add(tempDir);
589+
590+
// 更新文件引用
591+
savedFile = targetFile;
592+
} catch (IOException e) {
593+
log.error("Failed to move file from temp directory: {}", filePath, e);
594+
continue; // 跳过此文件
595+
}
596+
}
597+
517598
LocalDateTime currentTime = LocalDateTime.now();
518599
// 统一 fileName:无论是否通过文件夹/压缩包上传,都只保留纯文件名
519600
String originalFileName = file.getFileName();
@@ -539,6 +620,20 @@ private void addFileToDataset(String datasetId, List<FileUploadResult> unpacked)
539620
datasetFileRepository.saveOrUpdate(datasetFile);
540621
dataset.addFile(datasetFile);
541622
}
623+
624+
// 递归删除所有临时目录
625+
for (String tempDir : tempDirsToDelete) {
626+
try {
627+
File tempDirFile = new File(tempDir);
628+
if (tempDirFile.exists() && tempDirFile.isDirectory()) {
629+
org.apache.commons.io.FileUtils.deleteDirectory(tempDirFile);
630+
log.info("Deleted temp directory: {}", tempDir);
631+
}
632+
} catch (IOException e) {
633+
log.warn("Failed to delete temp directory: {}", tempDir, e);
634+
}
635+
}
636+
542637
dataset.active();
543638
datasetRepository.updateById(dataset);
544639
}
@@ -623,7 +718,7 @@ public void downloadDirectory(String datasetId, String prefix, HttpServletRespon
623718
try {
624719
response.setContentType("application/zip");
625720
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + zipFileName + "\"");
626-
721+
627722
try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(response.getOutputStream())) {
628723
zipDirectory(normalized, normalized, zipOut);
629724
zipOut.finish();

backend/shared/domain-common/src/main/java/com/datamate/common/domain/utils/ArchiveAnalyzer.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,23 @@ private static boolean checkUnpackSizeAndFileSize(int entryCount, List<FileUploa
143143
private static Optional<FileUploadResult> extractEntity(ArchiveInputStream<?> archiveInputStream, ArchiveEntry archiveEntry, Path archivePath)
144144
throws IOException {
145145
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
146-
Path path = Paths.get(archivePath.getParent().toString(), archiveEntry.getName());
146+
147+
// 防止 Zip Slip 攻击:验证归档条目名称
148+
String entryName = archiveEntry.getName();
149+
if (entryName.contains("..")) {
150+
log.warn("Path traversal attempt detected in archive entry: {}", entryName);
151+
return Optional.empty();
152+
}
153+
154+
Path parentDir = archivePath.getParent();
155+
Path path = parentDir.resolve(entryName).normalize();
156+
157+
// 确保解析后的路径仍然位于父目录内
158+
if (!path.startsWith(parentDir)) {
159+
log.warn("Zip Slip attempt detected: entry {} resolves outside parent directory", entryName);
160+
return Optional.empty();
161+
}
162+
147163
File file = path.toFile();
148164
long fileSize = 0L;
149165
FileUtils.createParentDirectories(file);

0 commit comments

Comments
 (0)