diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalTempCleanupService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalTempCleanupService.java index 4fa99d00..2306b84c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalTempCleanupService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalTempCleanupService.java @@ -57,6 +57,11 @@ public class LocalTempCleanupService { for (File child : children) { try { + // 路径安全:child 必须位于临时根目录内(防穿越/符号链接逃逸),否则跳过并告警 + if (!PathSafetyGuard.isInside(tempDir, child)) { + log.warn("local temp cleanup skipped unsafe path: {}", child.getAbsolutePath()); + continue; + } if (child.isFile() && isManagedRootTempFile(child) && isExpired(child, sourceExpireBefore)) { if (FileUtil.del(child)) { deletedSourceCount++; @@ -89,6 +94,9 @@ public class LocalTempCleanupService { File[] children = file.listFiles(); if (children != null) { for (File child : children) { + if (!PathSafetyGuard.isInside(file, child)) { + continue; + } deletedCount += deleteExpiredChildrenRecursively(child, expireBefore); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/PathSafetyGuard.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/PathSafetyGuard.java new file mode 100644 index 00000000..6cf1ca7f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/PathSafetyGuard.java @@ -0,0 +1,54 @@ +package com.nanri.aiimage.modules.file.service; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +/** + * 临时文件删除前的路径安全校验(task-151)。 + * + * 删除前校验:child 规范化后必须位于 root 内(startsWith(root) 且不等于 root); + * 穿越(../、绝对路径逃逸、符号链接逃逸)拒绝;null/非法输入返回 false。 + * 符号链接用 toRealPath 解析(存在时),防止链接指向根外文件被误删。 + */ +public final class PathSafetyGuard { + + private PathSafetyGuard() { + } + + /** + * child 是否位于 root 内(规范化比较;root 本身不算内部)。 + * 路径不存在时以 toAbsolutePath().normalize() 兜底,存在时优先 toRealPath + * 解析符号链接。 + */ + public static boolean isInside(File root, File child) { + if (root == null || child == null) { + return false; + } + Path rootPath = resolve(root); + Path childPath = resolve(child); + if (rootPath == null || childPath == null) { + return false; + } + return childPath.startsWith(rootPath) && !childPath.equals(rootPath); + } + + /** 路径名是否含穿越特征(../、绝对路径、盘符、反斜杠)。 */ + public static boolean isTraversal(String name) { + if (name == null || name.isBlank()) { + return true; + } + return name.contains("..") + || name.startsWith("/") + || name.matches("^[A-Za-z]:.*") + || name.contains("\\"); + } + + private static Path resolve(File file) { + try { + return file.toPath().toRealPath(); + } catch (IOException ex) { + return file.toPath().toAbsolutePath().normalize(); + } + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/PathSafetyGuardTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/PathSafetyGuardTest.java new file mode 100644 index 00000000..6bfc8d3f --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/PathSafetyGuardTest.java @@ -0,0 +1,97 @@ +package com.nanri.aiimage.modules.file.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * task-151:路径安全校验契约(plan 09)。 + * 删除前校验:child 规范化后 startsWith(临时根目录) 且不等于根; + * 穿越(../、绝对路径逃逸、符号链接逃逸)拒绝;非法输入安全返回 false。 + */ +class PathSafetyGuardTest { + + @TempDir + Path tempDir; + + @Test + void insideRootIsAllowed() throws Exception { + Path child = tempDir.resolve("a.tmp"); + Files.writeString(child, "x"); + + assertTrue(PathSafetyGuard.isInside(tempDir.toFile(), child.toFile())); + } + + @Test + void nestedDirectoryInsideRootIsAllowed() throws Exception { + Path nested = tempDir.resolve("result/2026/09").resolve("f.tmp"); + Files.createDirectories(nested.getParent()); + Files.writeString(nested, "x"); + + assertTrue(PathSafetyGuard.isInside(tempDir.toFile(), nested.toFile())); + } + + @Test + void parentTraversalIsRejected() { + assertTrue(PathSafetyGuard.isTraversal("../escape")); + assertTrue(PathSafetyGuard.isTraversal("a/../../b")); + assertFalse(PathSafetyGuard.isTraversal("normal-file.tmp")); + } + + @Test + void absolutePathEscapeIsRejected() { + assertTrue(PathSafetyGuard.isTraversal("/etc/passwd")); + assertTrue(PathSafetyGuard.isTraversal("C:\\windows\\x")); + assertTrue(PathSafetyGuard.isTraversal("D:/escape")); + } + + @Test + void outsideRootIsRejected() throws Exception { + Path outside = tempDir.getParent().resolve("outside-" + System.nanoTime() + ".tmp"); + Files.writeString(outside, "x"); + try { + assertFalse(PathSafetyGuard.isInside(tempDir.toFile(), outside.toFile()), + "根外路径必须拒绝"); + } finally { + Files.deleteIfExists(outside); + } + } + + @Test + void symlinkEscapeIsRejected() throws Exception { + Path outside = tempDir.getParent().resolve("symlink-target-" + System.nanoTime() + ".tmp"); + Files.writeString(outside, "secret"); + Path link = tempDir.resolve("link.tmp"); + try { + Files.createSymbolicLink(link, outside); + assertFalse(PathSafetyGuard.isInside(tempDir.toFile(), link.toFile()), + "符号链接指向根外必须拒绝(toRealPath 解析)"); + } catch (UnsupportedOperationException | java.io.IOException ex) { + // 平台不支持符号链接时跳过 + } finally { + Files.deleteIfExists(link); + Files.deleteIfExists(outside); + } + } + + @Test + void rootItselfIsNotDeletable() { + assertFalse(PathSafetyGuard.isInside(tempDir.toFile(), tempDir.toFile()), + "根目录本身不算内部(禁止删根)"); + } + + @Test + void nullAndBlankAreSafe() { + assertFalse(PathSafetyGuard.isInside(null, null)); + assertFalse(PathSafetyGuard.isInside(tempDir.toFile(), null)); + assertFalse(PathSafetyGuard.isInside(null, tempDir.toFile())); + assertTrue(PathSafetyGuard.isTraversal(null)); + assertTrue(PathSafetyGuard.isTraversal(" ")); + } +}