From f0e12b9b1b98a40a00e4ddb60d47a23a9c3935ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Wed, 2 Sep 2026 06:24:37 +0800 Subject: [PATCH] =?UTF-8?q?task-151:=20=E8=B7=AF=E5=BE=84=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=A0=A1=E9=AA=8C=EF=BC=88PathSafetyGuard=20=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E5=8C=96=20startsWith=20=E5=88=A4=E5=AE=9A=E3=80=81..?= =?UTF-8?q?/=E4=B8=8E=E7=BB=9D=E5=AF=B9=E8=B7=AF=E5=BE=84=E4=B8=8E?= =?UTF-8?q?=E7=AC=A6=E5=8F=B7=E9=93=BE=E6=8E=A5=E9=80=83=E9=80=B8=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E3=80=81=E6=8E=A5=E5=85=A5=E6=B8=85=E7=90=86=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=EF=BC=89+=208=20=E6=9D=A1=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/service/LocalTempCleanupService.java | 8 ++ .../modules/file/service/PathSafetyGuard.java | 54 +++++++++++ .../file/service/PathSafetyGuardTest.java | 97 +++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/file/service/PathSafetyGuard.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/file/service/PathSafetyGuardTest.java 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(" ")); + } +}