mirror of
https://gitee.com/kekingcn/file-online-preview.git
synced 2026-11-14 15:20:40 +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.FileHandlerService;
|
||||||
import cn.keking.service.CompressFileReader;
|
import cn.keking.service.CompressFileReader;
|
||||||
import cn.keking.utils.KkFileUtils;
|
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.commons.lang3.exception.ExceptionUtils;
|
||||||
import org.apache.poi.EncryptedDocumentException;
|
import org.apache.poi.EncryptedDocumentException;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -78,6 +80,12 @@ public class CompressFilePreviewImpl implements FilePreview {
|
|||||||
}
|
}
|
||||||
model.addAttribute("fileName", fileName);
|
model.addAttribute("fileName", fileName);
|
||||||
model.addAttribute("fileTree", fileTree);
|
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;
|
return COMPRESS_FILE_PREVIEW_PAGE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.File;
|
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.io.UnsupportedEncodingException;
|
||||||
import java.nio.charset.Charset;
|
import java.nio.charset.Charset;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -19,7 +25,6 @@ import java.util.regex.Pattern;
|
|||||||
**/
|
**/
|
||||||
public class RarUtils {
|
public class RarUtils {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(RarUtils.class);
|
private static final Logger logger = LoggerFactory.getLogger(RarUtils.class);
|
||||||
private static final String fileDir = ConfigConstants.getFileDir();
|
|
||||||
|
|
||||||
public static byte[] getUTF8BytesFromGBKString(String gbkStr) {
|
public static byte[] getUTF8BytesFromGBKString(String gbkStr) {
|
||||||
int n = gbkStr.length();
|
int n = gbkStr.length();
|
||||||
@@ -108,30 +113,40 @@ public class RarUtils {
|
|||||||
/**
|
/**
|
||||||
* 读取文件目录树
|
* 读取文件目录树
|
||||||
*/
|
*/
|
||||||
public static List<ZtreeNodeVo> getTree(String rootPath) {
|
public static List<ZtreeNodeVo> getTree(String rootPath) throws IOException {
|
||||||
List<ZtreeNodeVo> nodes = new ArrayList<>();
|
Path fileRoot = Paths.get(ConfigConstants.getFileDir()).toRealPath();
|
||||||
File file = new File(fileDir+rootPath);
|
Path archiveRoot = fileRoot.resolve(rootPath).normalize();
|
||||||
ZtreeNodeVo node = traverse(file);
|
if (archiveRoot.equals(fileRoot) || !archiveRoot.startsWith(fileRoot)) {
|
||||||
nodes.add(node);
|
throw new SecurityException("Only an individual archive directory may be listed");
|
||||||
return nodes;
|
|
||||||
}
|
}
|
||||||
private static ZtreeNodeVo traverse(File file) {
|
archiveRoot = archiveRoot.toRealPath();
|
||||||
ZtreeNodeVo pathNodeVo = new ZtreeNodeVo();
|
if (archiveRoot.equals(fileRoot) || !archiveRoot.startsWith(fileRoot) || !Files.isDirectory(archiveRoot)) {
|
||||||
pathNodeVo.setId(file.getAbsolutePath().replace(fileDir, "").replace("\\", "/"));
|
throw new SecurityException("Archive directory escapes file storage");
|
||||||
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) {
|
return List.of(traverse(archiveRoot, fileRoot, archiveRoot));
|
||||||
ZtreeNodeVo subNodeVo = traverse(subFile);
|
|
||||||
subNodeVos.add(subNodeVo);
|
|
||||||
}
|
}
|
||||||
pathNodeVo.setChildren(subNodeVos);
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
return pathNodeVo;
|
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.CaptchaUtil;
|
||||||
import cn.keking.utils.DateUtils;
|
import cn.keking.utils.DateUtils;
|
||||||
import cn.keking.utils.KkFileUtils;
|
import cn.keking.utils.KkFileUtils;
|
||||||
import cn.keking.utils.RarUtils;
|
|
||||||
import cn.keking.utils.WebUtils;
|
import cn.keking.utils.WebUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -814,20 +813,4 @@ public class FileController {
|
|||||||
return ReturnResponse.success(fileName);
|
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() {
|
function loadTree() {
|
||||||
var url = "http://" + '${fileTree}';
|
|
||||||
setTreeStatus("目录加载中", "is-loading");
|
setTreeStatus("目录加载中", "is-loading");
|
||||||
treeSummaryEl.textContent = "正在准备目录结构...";
|
treeSummaryEl.textContent = "正在准备目录结构...";
|
||||||
showTreeState("loading");
|
showTreeState("loading");
|
||||||
$.ajax({
|
initTree(JSON.parse('${archiveTreeJson?js_string}'));
|
||||||
type: "get",
|
|
||||||
url: "${baseUrl}directory?urls=" + encodeURIComponent(Base64.encode(url)),
|
|
||||||
success: function (res) {
|
|
||||||
initTree(res);
|
|
||||||
},
|
|
||||||
error: function () {
|
|
||||||
showTreeState("error");
|
|
||||||
setTreeStatus("目录加载失败", "is-error");
|
|
||||||
treeSummaryEl.textContent = "目录读取失败,请重试。";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("retryTree").addEventListener("click", function () {
|
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