task-151: 路径安全校验(PathSafetyGuard 规范化 startsWith 判定、../与绝对路径与符号链接逃逸拒绝、接入清理路径)+ 8 条测试

This commit is contained in:
2026-09-02 06:24:37 +08:00
parent ffc3963345
commit f0e12b9b1b
3 changed files with 159 additions and 0 deletions
@@ -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);
}
}
@@ -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();
}
}
}
@@ -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(" "));
}
}