diff --git a/server/src/main/java/cn/keking/service/impl/CompressFilePreviewImpl.java b/server/src/main/java/cn/keking/service/impl/CompressFilePreviewImpl.java index 13fc0791..de817baa 100644 --- a/server/src/main/java/cn/keking/service/impl/CompressFilePreviewImpl.java +++ b/server/src/main/java/cn/keking/service/impl/CompressFilePreviewImpl.java @@ -8,6 +8,8 @@ import cn.keking.utils.DownloadUtils; import cn.keking.service.FileHandlerService; import cn.keking.service.CompressFileReader; import cn.keking.utils.KkFileUtils; +import cn.keking.utils.RarUtils; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.poi.EncryptedDocumentException; import org.slf4j.Logger; @@ -78,6 +80,12 @@ public class CompressFilePreviewImpl implements FilePreview { } model.addAttribute("fileName", fileName); model.addAttribute("fileTree", fileTree); + try { + model.addAttribute("archiveTreeJson", new ObjectMapper().writeValueAsString(RarUtils.getTree(fileTree))); + } catch (IOException | SecurityException e) { + logger.warn("Unable to read the previewed archive directory", e); + return otherFilePreview.notSupportedFile(model, fileAttribute, "该压缩包目录无法读取!"); + } return COMPRESS_FILE_PREVIEW_PAGE; } } diff --git a/server/src/main/java/cn/keking/utils/RarUtils.java b/server/src/main/java/cn/keking/utils/RarUtils.java index 6a46d392..f5a140fa 100644 --- a/server/src/main/java/cn/keking/utils/RarUtils.java +++ b/server/src/main/java/cn/keking/utils/RarUtils.java @@ -5,6 +5,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.LinkOption; +import java.nio.file.DirectoryStream; import java.io.UnsupportedEncodingException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; @@ -19,7 +25,6 @@ import java.util.regex.Pattern; **/ public class RarUtils { private static final Logger logger = LoggerFactory.getLogger(RarUtils.class); - private static final String fileDir = ConfigConstants.getFileDir(); public static byte[] getUTF8BytesFromGBKString(String gbkStr) { int n = gbkStr.length(); @@ -108,30 +113,40 @@ public class RarUtils { /** * 读取文件目录树 */ - public static List getTree(String rootPath) { - List nodes = new ArrayList<>(); - File file = new File(fileDir+rootPath); - ZtreeNodeVo node = traverse(file); - nodes.add(node); - return nodes; - } - private static ZtreeNodeVo traverse(File file) { - ZtreeNodeVo pathNodeVo = new ZtreeNodeVo(); - pathNodeVo.setId(file.getAbsolutePath().replace(fileDir, "").replace("\\", "/")); - pathNodeVo.setName(file.getName()); - pathNodeVo.setPid(file.getParent().replace(fileDir, "").replace("\\", "/")); - if (file.isDirectory()) { - List subNodeVos = new ArrayList<>(); - File[] subFiles = file.listFiles(); - if (subFiles == null) { - return pathNodeVo; - } - for (File subFile : subFiles) { - ZtreeNodeVo subNodeVo = traverse(subFile); - subNodeVos.add(subNodeVo); - } - pathNodeVo.setChildren(subNodeVos); + public static List getTree(String rootPath) throws IOException { + Path fileRoot = Paths.get(ConfigConstants.getFileDir()).toRealPath(); + Path archiveRoot = fileRoot.resolve(rootPath).normalize(); + if (archiveRoot.equals(fileRoot) || !archiveRoot.startsWith(fileRoot)) { + throw new SecurityException("Only an individual archive directory may be listed"); } - return pathNodeVo; + archiveRoot = archiveRoot.toRealPath(); + if (archiveRoot.equals(fileRoot) || !archiveRoot.startsWith(fileRoot) || !Files.isDirectory(archiveRoot)) { + throw new SecurityException("Archive directory escapes file storage"); + } + return List.of(traverse(archiveRoot, fileRoot, archiveRoot)); + } + + private static ZtreeNodeVo traverse(Path file, Path fileRoot, Path archiveRoot) throws IOException { + ZtreeNodeVo node = new ZtreeNodeVo(); + node.setId(fileRoot.relativize(file).toString().replace('\\', '/')); + node.setName(file.getFileName().toString()); + node.setPid(file.equals(archiveRoot) ? "" : fileRoot.relativize(file.getParent()).toString().replace('\\', '/')); + if (Files.isDirectory(file, LinkOption.NOFOLLOW_LINKS)) { + List children = new ArrayList<>(); + try (DirectoryStream entries = Files.newDirectoryStream(file)) { + for (Path child : entries) { + // Do not follow links into other cached documents or outside storage. + if (Files.isSymbolicLink(child)) { + continue; + } + if (Files.isRegularFile(child, LinkOption.NOFOLLOW_LINKS) + || Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) { + children.add(traverse(child, fileRoot, archiveRoot)); + } + } + } + node.setChildren(children); + } + return node; } } 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 67a2730b..57e77e06 100644 --- a/server/src/main/java/cn/keking/web/controller/FileController.java +++ b/server/src/main/java/cn/keking/web/controller/FileController.java @@ -6,7 +6,6 @@ import cn.keking.model.ReturnResponse; import cn.keking.utils.CaptchaUtil; import cn.keking.utils.DateUtils; import cn.keking.utils.KkFileUtils; -import cn.keking.utils.RarUtils; import cn.keking.utils.WebUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -814,20 +813,4 @@ public class FileController { return ReturnResponse.success(fileName); } - @GetMapping("/directory") - public Object directory(String urls) { - String fileUrl; - try { - fileUrl = WebUtils.decodeUrl(urls,"base64"); - } catch (Exception ex) { - String errorMsg = String.format(BASE64_DECODE_ERROR_MSG, "url"); - return ReturnResponse.failure(errorMsg); - } - fileUrl = fileUrl.replaceAll("http://", ""); - if (KkFileUtils.isIllegalFileName(fileUrl)) { - return ReturnResponse.failure("不允许访问的路径:"); - } - return RarUtils.getTree(fileUrl); - } - } diff --git a/server/src/main/resources/web/compress.ftl b/server/src/main/resources/web/compress.ftl index d40aef7c..8be245d7 100644 --- a/server/src/main/resources/web/compress.ftl +++ b/server/src/main/resources/web/compress.ftl @@ -944,22 +944,10 @@ } function loadTree() { - var url = "http://" + '${fileTree}'; setTreeStatus("目录加载中", "is-loading"); treeSummaryEl.textContent = "正在准备目录结构..."; showTreeState("loading"); - $.ajax({ - type: "get", - url: "${baseUrl}directory?urls=" + encodeURIComponent(Base64.encode(url)), - success: function (res) { - initTree(res); - }, - error: function () { - showTreeState("error"); - setTreeStatus("目录加载失败", "is-error"); - treeSummaryEl.textContent = "目录读取失败,请重试。"; - } - }); + initTree(JSON.parse('${archiveTreeJson?js_string}')); } document.getElementById("retryTree").addEventListener("click", function () { diff --git a/server/src/test/java/cn/keking/web/controller/ArchiveDirectorySecurityTests.java b/server/src/test/java/cn/keking/web/controller/ArchiveDirectorySecurityTests.java new file mode 100644 index 00000000..a8c32cf8 --- /dev/null +++ b/server/src/test/java/cn/keking/web/controller/ArchiveDirectorySecurityTests.java @@ -0,0 +1,84 @@ +package cn.keking.web.controller; + +import cn.keking.config.ConfigConstants; +import cn.keking.model.FileAttribute; +import cn.keking.service.FileHandlerService; +import cn.keking.service.ZtreeNodeVo; +import cn.keking.service.impl.CompressFilePreviewImpl; +import cn.keking.utils.RarUtils; +import freemarker.template.Configuration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.ui.ExtendedModelMap; + +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ArchiveDirectorySecurityTests { + @TempDir Path temp; + private String previousDir; + private Boolean previousCache; + @BeforeEach void setup() throws Exception { + previousDir = ConfigConstants.getFileDir(); + previousCache = ConfigConstants.isCacheEnabled(); + ConfigConstants.setFileDirValue(temp.toString()); + ConfigConstants.setCacheEnabledValueValue(true); + Files.createDirectories(temp.resolve("archive.zip_/nested")); + Files.writeString(temp.resolve("archive.zip_/nested/visible.txt"), "visible"); + Files.writeString(temp.resolve("private-document.pdf"), "secret"); + } + @AfterEach void restore() { + ConfigConstants.setFileDirValue(previousDir); + ConfigConstants.setCacheEnabledValueValue(previousCache); + } + + @Test void publicDirectoryEnumerationRouteIsAbsent() throws Exception { + MockMvcBuilders.standaloneSetup(new FileController()).build() + .perform(get("/directory").param("urls", "aHR0cDovLy8=")) + .andExpect(status().isNotFound()); + } + @Test void treeIsConfinedAndDoesNotFollowLinksOrExposeAbsoluteParents() throws Exception { + Files.createSymbolicLink(temp.resolve("archive.zip_/secret-link"), temp.resolve("private-document.pdf")); + Files.createSymbolicLink(temp.resolve("archive.zip_/loop"), temp.resolve("archive.zip_")); + List tree = RarUtils.getTree("archive.zip_"); + assertEquals("", tree.get(0).pid); + assertEquals(1, tree.get(0).children.size()); + assertEquals("archive.zip_/nested/visible.txt", tree.get(0).children.get(0).children.get(0).id); + assertThrows(SecurityException.class, () -> RarUtils.getTree("")); + assertThrows(SecurityException.class, () -> RarUtils.getTree("/")); + assertThrows(SecurityException.class, () -> RarUtils.getTree("../")); + } + @Test void cachedArchivePreviewRendersItsTreeWithoutAnEnumerationRequest() throws Exception { + Files.writeString(temp.resolve("archive.zip_/quote'\"