mirror of
https://gitee.com/kekingcn/file-online-preview.git
synced 2026-11-11 13:50:44 +00:00
fix: render archive trees only within authorized previews
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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\\'"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user