task-151: 路径安全校验(PathSafetyGuard 规范化 startsWith 判定、../与绝对路径与符号链接逃逸拒绝、接入清理路径)+ 8 条测试
This commit is contained in:
+8
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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(" "));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user