task-151: 路径安全校验(PathSafetyGuard 规范化 startsWith 判定、../与绝对路径与符号链接逃逸拒绝、接入清理路径)+ 8 条测试
This commit is contained in:
+8
@@ -57,6 +57,11 @@ public class LocalTempCleanupService {
|
|||||||
|
|
||||||
for (File child : children) {
|
for (File child : children) {
|
||||||
try {
|
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 (child.isFile() && isManagedRootTempFile(child) && isExpired(child, sourceExpireBefore)) {
|
||||||
if (FileUtil.del(child)) {
|
if (FileUtil.del(child)) {
|
||||||
deletedSourceCount++;
|
deletedSourceCount++;
|
||||||
@@ -89,6 +94,9 @@ public class LocalTempCleanupService {
|
|||||||
File[] children = file.listFiles();
|
File[] children = file.listFiles();
|
||||||
if (children != null) {
|
if (children != null) {
|
||||||
for (File child : children) {
|
for (File child : children) {
|
||||||
|
if (!PathSafetyGuard.isInside(file, child)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
deletedCount += deleteExpiredChildrenRecursively(child, expireBefore);
|
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