fix: render archive trees only within authorized previews

This commit is contained in:
kl
2026-10-08 14:17:07 +08:00
parent 84369c032b
commit 8530c0a713
5 changed files with 133 additions and 55 deletions

View File

@@ -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;
}
}

View File

@@ -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<ZtreeNodeVo> getTree(String rootPath) {
List<ZtreeNodeVo> 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<ZtreeNodeVo> 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<ZtreeNodeVo> 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<ZtreeNodeVo> children = new ArrayList<>();
try (DirectoryStream<Path> 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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 () {

View File

@@ -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<ZtreeNodeVo> 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'\"<script>.txt"), "safe");
FileHandlerService handler = new FileHandlerService(null) {
@Override public String getConvertedFile(String name) { return "archive.zip_"; }
};
FileAttribute file = new FileAttribute();
file.setName("archive.zip");
ExtendedModelMap model = new ExtendedModelMap();
assertEquals("compress", new CompressFilePreviewImpl(handler, null, null)
.filePreviewHandle("https://files.example/archive.zip", model, file));
String json = (String) model.get("archiveTreeJson");
assertTrue(json.contains("nested/visible.txt"));
assertFalse(json.contains("private-document.pdf"));
assertFalse(json.contains(temp.toString()));
model.put("baseUrl", "https://preview.example/");
for (String name : new String[]{"watermarkXSpace", "watermarkYSpace", "watermarkAlpha",
"watermarkWidth", "watermarkHeight", "watermarkAngle"}) { model.put(name, "0"); }
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setClassForTemplateLoading(getClass(), "/web");
cfg.setClassicCompatible(true);
StringWriter output = new StringWriter();
cfg.getTemplate("compress.ftl").process(model, output);
assertTrue(output.toString().contains("initTree(JSON.parse('"));
assertFalse(output.toString().contains("directory?urls="));
assertTrue(output.toString().contains("quote\\'"));
}
}