From 47745e4d74112000fcc4f0664e2fc751e6cf9bae Mon Sep 17 00:00:00 2001 From: kl Date: Mon, 13 Jul 2026 16:53:58 +0800 Subject: [PATCH] Merge commit from fork --- .../keking/web/controller/FileController.java | 67 +++++++++++-- .../FileControllerPathSecurityTests.java | 98 +++++++++++++++++++ 2 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 server/src/test/java/cn/keking/web/controller/FileControllerPathSecurityTests.java diff --git a/server/src/main/java/cn/keking/web/controller/FileController.java b/server/src/main/java/cn/keking/web/controller/FileController.java index 247b6204..793abe6d 100644 --- a/server/src/main/java/cn/keking/web/controller/FileController.java +++ b/server/src/main/java/cn/keking/web/controller/FileController.java @@ -29,6 +29,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.nio.file.DirectoryStream; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.attribute.BasicFileAttributes; @@ -341,13 +342,23 @@ public class FileController { } // ==================== 2. 构建路径和验证 ==================== - String basePath = fileDir + demoPath; - if (!ObjectUtils.isEmpty(path)) { - basePath += path + File.separator; + Path currentDir; + try { + currentDir = resolveDirectoryUnderRoot(Paths.get(fileDir, demoDir), path); + } catch (InvalidPathException | SecurityException e) { + logger.warn("拒绝访问 demo 目录之外的文件列表路径"); + result.put("total", 0); + result.put("data", Collections.emptyList()); + result.put("error", "非法目录路径"); + return result; + } catch (IOException e) { + logger.error("解析 demo 目录失败", e); + result.put("total", 0); + result.put("data", Collections.emptyList()); + return result; } - File currentDir = new File(basePath); - if (!currentDir.exists() || !currentDir.isDirectory()) { + if (!Files.isDirectory(currentDir)) { result.put("total", 0); result.put("data", Collections.emptyList()); return result; @@ -357,13 +368,13 @@ public class FileController { List allPaths = new ArrayList<>(); long collectStartTime = System.currentTimeMillis(); - try (DirectoryStream stream = Files.newDirectoryStream(Paths.get(basePath))) { + try (DirectoryStream stream = Files.newDirectoryStream(currentDir)) { for (Path entry : stream) { allPaths.add(entry); stats.incrementFileCount(); } } catch (IOException e) { - logger.error("读取目录失败: {}", basePath, e); + logger.error("读取目录失败: {}", currentDir, e); result.put("total", 0); result.put("data", Collections.emptyList()); return result; @@ -492,6 +503,46 @@ public class FileController { return result; } + /** + * Resolve an existing directory below the configured demo root. + * + *

Both lexical normalization and real-path checks are required: the + * former blocks traversal and absolute paths, while the latter prevents a + * symlink inside the demo directory from escaping the configured root.

+ */ + static Path resolveDirectoryUnderRoot(Path root, String requestedPath) throws IOException { + Path normalizedRoot = root.toAbsolutePath().normalize(); + String relativePath = requestedPath == null ? "" : requestedPath.replace('\\', '/'); + + if (relativePath.indexOf('\0') >= 0 + || relativePath.startsWith("/") + || relativePath.matches("^[A-Za-z]:.*")) { + throw new SecurityException("Absolute paths are not allowed"); + } + + Path relative = Paths.get(relativePath); + if (relative.isAbsolute()) { + throw new SecurityException("Absolute paths are not allowed"); + } + for (Path segment : relative) { + if ("..".equals(segment.toString())) { + throw new SecurityException("Parent path segments are not allowed"); + } + } + + Path resolved = normalizedRoot.resolve(relative).normalize(); + if (!resolved.startsWith(normalizedRoot)) { + throw new SecurityException("Path escapes the configured root"); + } + + Path realRoot = normalizedRoot.toRealPath(); + Path realResolved = resolved.toRealPath(); + if (!realResolved.startsWith(realRoot)) { + throw new SecurityException("Path escapes the configured root through a symbolic link"); + } + return realResolved; + } + /** * 构建性能统计信息 */ @@ -760,4 +811,4 @@ public class FileController { File file = new File(fullPath + fileName); return file.exists(); } -} \ No newline at end of file +} diff --git a/server/src/test/java/cn/keking/web/controller/FileControllerPathSecurityTests.java b/server/src/test/java/cn/keking/web/controller/FileControllerPathSecurityTests.java new file mode 100644 index 00000000..f4b9dd51 --- /dev/null +++ b/server/src/test/java/cn/keking/web/controller/FileControllerPathSecurityTests.java @@ -0,0 +1,98 @@ +package cn.keking.web.controller; + +import cn.keking.config.ConfigConstants; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FileControllerPathSecurityTests { + + @TempDir + Path tempDir; + + private String originalFileDir; + + @BeforeEach + void rememberConfiguredFileDirectory() { + originalFileDir = ConfigConstants.getFileDir(); + } + + @AfterEach + void restoreConfiguredFileDirectory() { + ConfigConstants.setFileDirValue(originalFileDir); + } + + @Test + void shouldResolveDirectoriesInsideDemoRoot() throws IOException { + Path demoRoot = Files.createDirectory(tempDir.resolve("demo")); + Path nested = Files.createDirectories(demoRoot.resolve("folder/subfolder")); + + assertEquals(demoRoot.toRealPath(), FileController.resolveDirectoryUnderRoot(demoRoot, "")); + assertEquals(nested.toRealPath(), FileController.resolveDirectoryUnderRoot(demoRoot, "folder/subfolder")); + assertEquals(nested.toRealPath(), FileController.resolveDirectoryUnderRoot(demoRoot, "folder\\subfolder")); + } + + @Test + void shouldRejectParentTraversalWithEitherSeparator() throws IOException { + Path demoRoot = Files.createDirectory(tempDir.resolve("demo")); + + assertThrows(SecurityException.class, + () -> FileController.resolveDirectoryUnderRoot(demoRoot, "../outside")); + assertThrows(SecurityException.class, + () -> FileController.resolveDirectoryUnderRoot(demoRoot, "..\\outside")); + assertThrows(SecurityException.class, + () -> FileController.resolveDirectoryUnderRoot(demoRoot, "folder/../outside")); + } + + @Test + void shouldRejectAbsoluteDriveAndUncPaths() throws IOException { + Path demoRoot = Files.createDirectory(tempDir.resolve("demo")); + + assertThrows(SecurityException.class, + () -> FileController.resolveDirectoryUnderRoot(demoRoot, "/etc")); + assertThrows(SecurityException.class, + () -> FileController.resolveDirectoryUnderRoot(demoRoot, "C:\\Windows")); + assertThrows(SecurityException.class, + () -> FileController.resolveDirectoryUnderRoot(demoRoot, "\\\\server\\share")); + } + + @Test + void shouldRejectSymlinkThatEscapesDemoRoot() throws IOException { + Path demoRoot = Files.createDirectory(tempDir.resolve("demo")); + Path outside = Files.createDirectory(tempDir.resolve("outside")); + Path link = demoRoot.resolve("outside-link"); + try { + Files.createSymbolicLink(link, outside); + } catch (IOException | UnsupportedOperationException e) { + Assumptions.assumeTrue(false, "Symbolic links are unavailable in this environment"); + } + + assertThrows(SecurityException.class, + () -> FileController.resolveDirectoryUnderRoot(demoRoot, "outside-link")); + } + + @Test + void listFilesShouldNotExposeEntriesOutsideDemoRoot() throws IOException { + Files.createDirectory(tempDir.resolve("demo")); + Files.createFile(tempDir.resolve("outside-secret.txt")); + ConfigConstants.setFileDirValue(tempDir.toString()); + FileController controller = new FileController(); + + Map result = controller.getFiles("..", "", 0, 20, null, null); + + assertEquals("非法目录路径", result.get("error")); + assertTrue(((List) result.get("data")).isEmpty()); + } +}