Compare commits

..

1 Commits

Author SHA1 Message Date
kl
7323447468 Add security policy 2026-06-29 14:55:23 +08:00
17 changed files with 79 additions and 618 deletions

View File

@@ -1,4 +1,4 @@
FROM keking/kkfileview-base:5.0.0 FROM keking/kkfileview-base:5.0.0
ADD server/target/kkFileView-*.tar.gz /opt/ ADD server/target/kkFileView-*.tar.gz /opt/
ENV KKFILEVIEW_BIN_FOLDER=/opt/kkFileView-5.0.2/bin ENV KKFILEVIEW_BIN_FOLDER=/opt/kkFileView-5.0.0/bin
ENTRYPOINT ["java","-Dfile.encoding=UTF-8","-Dspring.config.location=/opt/kkFileView-5.0.2/config/application.properties","-jar","/opt/kkFileView-5.0.2/bin/kkFileView-5.0.2.jar"] ENTRYPOINT ["java","-Dfile.encoding=UTF-8","-Dspring.config.location=/opt/kkFileView-5.0.0/config/application.properties","-jar","/opt/kkFileView-5.0.0/bin/kkFileView-5.0.0.jar"]

View File

@@ -149,44 +149,6 @@ pdf预览模式预览效果如下
### 历史更新记录 ### 历史更新记录
#### > 2026年08月14日v5.0.2 补丁版本发布
#### 安全修复
1. 将不可信 HTML 预览放入不具有同源权限的 iframe 沙箱并默认禁用其中的 JavaScript避免被预览文件在 kkFileView 应用源中执行脚本GHSA-9wcf-jxxf-w2g2
2. 默认禁用演示文件删除接口将接口改为 POST并要求显式配置密码后进行精确比较GHSA-f3qx-xrwc-5428
#### 修复问题
1. PDF 转图服务启动时刷新 ImageIO 插件使 JBIG2 等嵌套 JAR 图像读取器能够被发现避免 PDF 转图片预览时部分图像丢失
#### 升级说明
1. 建议所有 v5.0.1 及更早版本用户尽快升级到 v5.0.2
2. 本版本继续要求 JDK 21 及以上现有 v5.0.1 配置可直接沿用
3. 文件删除功能现在默认禁用如确需启用请通过 `KK_DELETE_PASSWORD` 或外部 `delete.password` 设置独立强密码并将 `/deleteFile` 调用改为 POST
4. `kk.scriptjs` 现在默认为 `false`显式启用后脚本仍只会在隔离的 iframe 沙箱内运行
#### > 2026年07月13日v5.0.1 补丁版本发布
#### 安全修复
1. 修复 `/addTask` 未经过信任主机和本地目录过滤可能导致服务端请求伪造SSRF的问题GHSA-gwwj-52hv-6g2m
2. 修复 `/listFiles` `directory` 参数可越出演示目录造成路径遍历和目录信息泄露的问题GHSA-pmp8-g8p2-p6jq
#### 修复问题
1. 修复 PDF 跨域页码定位文本高亮打印和打印水印相关问题
2. 修复 PDF 在反向代理场景下的绝对路径问题以及水印和高亮内容包含特殊字符时的解析失败
3. 修复 Redis 单机集群主从哨兵模式配置不一致和地址协议缺失问题
4. 修复下载 MIME 类型校验失败后仍返回成功HTTP 错误原因不明确以及共享 HTTP Client 被错误关闭的问题
5. 修复 LuckyExcel 数据校验类型未映射时的 xlsx 解析崩溃
#### 优化内容
1. 大型 xlsx 文件改用 Web Worker 执行 LuckyExcel 解析并在 Worker 不可用或异常时自动回退主线程
2. 新增 `pdf.sidebar.open` 配置可控制 PDF 预览是否默认打开侧栏
3. Maven CI 增加 LinuxWindowsmacOS 构建验证
4. 新增仓库安全策略和私密漏洞报告入口
#### 升级说明
1. 建议所有 v5.0.0 及更早版本用户尽快升级到 v5.0.1
2. 本版本继续要求 JDK 21 及以上现有 v5.0.0 配置可直接沿用
#### > 2026年04月14日v5.0.0 版本发布 #### > 2026年04月14日v5.0.0 版本发布
#### 优化内容 #### 优化内容
1. xlsx 前端解析优化 - 提升Excel文件前端渲染性能 1. xlsx 前端解析优化 - 提升Excel文件前端渲染性能

View File

@@ -65,44 +65,6 @@ URL[https://file.kkview.cn](https://file.kkview.cn)
## Change History ## Change History
### Version 5.0.2 (August 14, 2026)
#### Security Fixes
1. Sandboxed untrusted HTML previews in an opaque-origin iframe and disabled embedded JavaScript by default, preventing previewed files from executing in the kkFileView application origin (GHSA-9wcf-jxxf-w2g2)
2. Disabled the demo file deletion endpoint by default, changed it to POST, and required an explicitly configured password with exact comparison (GHSA-f3qx-xrwc-5428)
#### Fixes
1. Refreshed ImageIO plugins when PDF conversion starts so nested JAR providers such as the JBIG2 reader are discovered, preventing images from disappearing in PDF-to-image previews
#### Upgrade Notes
1. All users running v5.0.1 or earlier are strongly encouraged to upgrade to v5.0.2
2. JDK 21 or higher remains required, and existing v5.0.1 configuration can be reused
3. File deletion is now disabled unless `KK_DELETE_PASSWORD` or an external `delete.password` is set to an independent strong password; integrations must call `/deleteFile` with POST
4. `kk.scriptjs` now defaults to `false`; when explicitly enabled, scripts still run only inside the isolated iframe sandbox
### Version 5.0.1 (July 13, 2026)
#### Security Fixes
1. Fixed `/addTask` bypassing trusted-host and local-directory filters, which could allow server-side request forgery (SSRF) (GHSA-gwwj-52hv-6g2m)
2. Fixed the `/listFiles` `directory` parameter escaping the demo directory, which could allow path traversal and directory information disclosure (GHSA-pmp8-g8p2-p6jq)
#### Fixes
1. Fixed PDF cross-origin access, page positioning, text highlighting, printing, and print watermark issues
2. Fixed PDF absolute paths behind reverse proxies and parsing failures when watermark or highlight text contains special characters
3. Fixed inconsistent Redis settings across standalone, cluster, master-replica, and sentinel modes, including missing address protocols
4. Fixed successful responses after MIME validation failures, unclear HTTP error reporting, and accidental closure of a shared HTTP client
5. Fixed xlsx parsing crashes when LuckyExcel data-validation types have no mapping
#### Improvements
1. Moved LuckyExcel parsing for large xlsx files into a Web Worker, with automatic main-thread fallback when the Worker is unavailable or fails
2. Added `pdf.sidebar.open` to control whether the PDF sidebar opens by default
3. Added Linux, Windows, and macOS validation to Maven CI
4. Added a repository security policy and private vulnerability reporting guidance
#### Upgrade Notes
1. All users running v5.0.0 or earlier are strongly encouraged to upgrade to v5.0.1
2. JDK 21 or higher remains required, and existing v5.0.0 configuration can be reused
### Version 5.0.0 (April 14, 2026) ### Version 5.0.0 (April 14, 2026)
#### Improvements #### Improvements

View File

@@ -6,7 +6,7 @@
<groupId>cn.keking</groupId> <groupId>cn.keking</groupId>
<artifactId>kkFileView-parent</artifactId> <artifactId>kkFileView-parent</artifactId>
<version>5.0.2</version> <version>5.0.0</version>
<properties> <properties>
<!-- ========== Java 和编译配置 ========== --> <!-- ========== Java 和编译配置 ========== -->

View File

@@ -6,7 +6,7 @@
<parent> <parent>
<artifactId>kkFileView-parent</artifactId> <artifactId>kkFileView-parent</artifactId>
<groupId>cn.keking</groupId> <groupId>cn.keking</groupId>
<version>5.0.2</version> <version>5.0.0</version>
</parent> </parent>
<artifactId>kkFileView</artifactId> <artifactId>kkFileView</artifactId>

View File

@@ -408,9 +408,8 @@ home.pagesize = ${DEFAULT_HOME_PAGSIZE:20}
# 启用后删除文件需要输入验证码防止误删 # 启用后删除文件需要输入验证码防止误删
delete.captcha = ${KK_DELETE_CAPTCHA:false} delete.captcha = ${KK_DELETE_CAPTCHA:false}
# 删除文件密码默认为false禁用删除接口 # 删除文件密码默认为123456
# 如需启用删除功能请通过环境变量或外部配置设置独立的强密码 delete.password = ${KK_DELETE_PASSWORD:123456}
delete.password = ${KK_DELETE_PASSWORD:false}
# 是否删除转换后的源文件默认为true删除 # 是否删除转换后的源文件默认为true删除
# 启用可节约磁盘空间但会丢失原始文件 # 启用可节约磁盘空间但会丢失原始文件
@@ -470,8 +469,8 @@ kk.xlsxshowtoolbar = false
# 首页是否显示key密钥 默认为false禁用 # 首页是否显示key密钥 默认为false禁用
kk.isshowkey = false kk.isshowkey = false
# 预览html文件 是否在隔离沙箱中启用JavaScript默认为false # 预览html文件 是否启用JavaScript 默认为true
kk.scriptjs = false kk.scriptjs = true
############################################################################### ###############################################################################

View File

@@ -405,8 +405,8 @@ home.pagesize = ${DEFAULT_HOME_PAGSIZE:20}
# 启用后删除文件需要输入验证码防止误删 # 启用后删除文件需要输入验证码防止误删
delete.captcha = ${KK_DELETE_CAPTCHA:false} delete.captcha = ${KK_DELETE_CAPTCHA:false}
# 删除文件密码默认为false禁用删除接口 # 删除文件密码默认为123456
delete.password = ${KK_DELETE_PASSWORD:false} delete.password = ${KK_DELETE_PASSWORD:123456}
# 是否删除转换后的源文件默认为true删除 # 是否删除转换后的源文件默认为true删除
# 启用可节约磁盘空间但会丢失原始文件 # 启用可节约磁盘空间但会丢失原始文件
@@ -466,8 +466,8 @@ kk.xlsxshowtoolbar = true
# 首页是否显示key密钥 默认为false禁用 # 首页是否显示key密钥 默认为false禁用
kk.isshowkey = true kk.isshowkey = true
# 预览html文件 是否在隔离沙箱中启用JavaScript默认为false # 预览html文件 是否启用JavaScript 默认为true
kk.scriptjs = false kk.scriptjs = true
############################################################################### ###############################################################################

View File

@@ -31,7 +31,7 @@ public class ConfigConstants {
// ================================================== // ==================================================
public static final String DEFAULT_VALUE = "default"; public static final String DEFAULT_VALUE = "default";
public static final String DEFAULT_SHOW_AES_KEY = "1234567890123456"; public static final String DEFAULT_SHOW_AES_KEY = "1234567890123456";
public static final String DEFAULT_PASSWORD = "false"; public static final String DEFAULT_PASSWORD = "123456";
public static final String DEFAULT_SIZE = "500MB"; public static final String DEFAULT_SIZE = "500MB";
public static final String DEFAULT_ENABLE_REFRECSHSCHEDULE = "5"; public static final String DEFAULT_ENABLE_REFRECSHSCHEDULE = "5";
public static final String DEFAULT_IS_JAVASCRIPT = "false"; public static final String DEFAULT_IS_JAVASCRIPT = "false";
@@ -664,7 +664,7 @@ public class ConfigConstants {
public void setSize(String size) { setSizeValue(size); } public void setSize(String size) { setSizeValue(size); }
public static void setSizeValue(String size) { ConfigConstants.size = size; } public static void setSizeValue(String size) { ConfigConstants.size = size; }
@Value("${delete.password:false}") @Value("${delete.password:123456}")
public void setPassword(String password) { setPasswordValue(password); } public void setPassword(String password) { setPasswordValue(password); }
public static void setPasswordValue(String password) { ConfigConstants.password = password; } public static void setPasswordValue(String password) { ConfigConstants.password = password; }

View File

@@ -18,7 +18,6 @@ import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils; import org.springframework.util.ObjectUtils;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@@ -94,8 +93,6 @@ public class PdfToJpgService {
@PostConstruct @PostConstruct
public void init() { public void init() {
refreshImageIoPlugins();
int maxThreads = ConfigConstants.getPdfMaxThreads(); int maxThreads = ConfigConstants.getPdfMaxThreads();
// 使用固定大小的虚拟线程池 // 使用固定大小的虚拟线程池
this.virtualThreadExecutor = Executors.newFixedThreadPool(maxThreads, this.virtualThreadExecutor = Executors.newFixedThreadPool(maxThreads,
@@ -107,13 +104,6 @@ public class PdfToJpgService {
scheduleCacheCleanup(); scheduleCacheCleanup();
} }
static void refreshImageIoPlugins() {
// ImageIO only scans once automatically. If another launcher or Java agent initializes
// it before Spring Boot installs its application class loader, nested JAR providers such
// as jbig2-imageio remain invisible until the application class path is scanned again.
ImageIO.scanForPlugins();
}
@PreDestroy @PreDestroy
public void shutdown() { public void shutdown() {
logger.info("开始关闭PDF转换服务..."); logger.info("开始关闭PDF转换服务...");

View File

@@ -11,7 +11,6 @@ import cn.keking.utils.WebUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.util.ObjectUtils; import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.util.StreamUtils; import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -30,12 +29,9 @@ import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.nio.file.DirectoryStream; import java.nio.file.DirectoryStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*; import java.util.*;
import static cn.keking.utils.CaptchaUtil.CAPTCHA_CODE; import static cn.keking.utils.CaptchaUtil.CAPTCHA_CODE;
@@ -221,7 +217,7 @@ public class FileController {
} }
} }
@PostMapping("/deleteFile") @GetMapping("/deleteFile")
public ReturnResponse<Object> deleteFile(HttpServletRequest request, String fileName, String password) { public ReturnResponse<Object> deleteFile(HttpServletRequest request, String fileName, String password) {
ReturnResponse<Object> checkResult = this.deleteFileCheck(request, fileName, password); ReturnResponse<Object> checkResult = this.deleteFileCheck(request, fileName, password);
if (checkResult.isFailure()) { if (checkResult.isFailure()) {
@@ -345,23 +341,13 @@ public class FileController {
} }
// ==================== 2. 构建路径和验证 ==================== // ==================== 2. 构建路径和验证 ====================
Path currentDir; String basePath = fileDir + demoPath;
try { if (!ObjectUtils.isEmpty(path)) {
currentDir = resolveDirectoryUnderRoot(Paths.get(fileDir, demoDir), path); basePath += path + File.separator;
} 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;
} }
if (!Files.isDirectory(currentDir)) { File currentDir = new File(basePath);
if (!currentDir.exists() || !currentDir.isDirectory()) {
result.put("total", 0); result.put("total", 0);
result.put("data", Collections.emptyList()); result.put("data", Collections.emptyList());
return result; return result;
@@ -371,13 +357,13 @@ public class FileController {
List<Path> allPaths = new ArrayList<>(); List<Path> allPaths = new ArrayList<>();
long collectStartTime = System.currentTimeMillis(); long collectStartTime = System.currentTimeMillis();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(currentDir)) { try (DirectoryStream<Path> stream = Files.newDirectoryStream(Paths.get(basePath))) {
for (Path entry : stream) { for (Path entry : stream) {
allPaths.add(entry); allPaths.add(entry);
stats.incrementFileCount(); stats.incrementFileCount();
} }
} catch (IOException e) { } catch (IOException e) {
logger.error("读取目录失败: {}", currentDir, e); logger.error("读取目录失败: {}", basePath, e);
result.put("total", 0); result.put("total", 0);
result.put("data", Collections.emptyList()); result.put("data", Collections.emptyList());
return result; return result;
@@ -506,46 +492,6 @@ public class FileController {
return result; return result;
} }
/**
* Resolve an existing directory below the configured demo root.
*
* <p>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.</p>
*/
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;
}
/** /**
* 构建性能统计信息 * 构建性能统计信息
*/ */
@@ -778,22 +724,11 @@ public class FileController {
return ReturnResponse.failure("密码 or 验证码为空,删除失败!"); return ReturnResponse.failure("密码 or 验证码为空,删除失败!");
} }
boolean captchaEnabled = ConfigConstants.getDeleteCaptcha(); String expectedPassword = ConfigConstants.getDeleteCaptcha() ?
String expectedPassword = captchaEnabled ?
WebUtils.getSessionAttr(request, CAPTCHA_CODE) : WebUtils.getSessionAttr(request, CAPTCHA_CODE) :
ConfigConstants.getPassword(); ConfigConstants.getPassword();
if (!captchaEnabled && (!StringUtils.hasText(expectedPassword) if (!password.equalsIgnoreCase(expectedPassword)) {
|| "false".equalsIgnoreCase(expectedPassword))) {
return ReturnResponse.failure("文件删除接口已禁用,请先配置 delete.password");
}
if (!StringUtils.hasText(expectedPassword)) {
return ReturnResponse.failure("验证码已失效,请刷新后重试!");
}
if (!MessageDigest.isEqual(password.getBytes(StandardCharsets.UTF_8),
expectedPassword.getBytes(StandardCharsets.UTF_8))) {
logger.error("删除文件【{}】失败,密码错误!", fileName); logger.error("删除文件【{}】失败,密码错误!", fileName);
return ReturnResponse.failure("删除文件失败,密码错误!"); return ReturnResponse.failure("删除文件失败,密码错误!");
} }

View File

@@ -16,22 +16,6 @@
<script src="js/jsformat.js" type="text/javascript"></script> <script src="js/jsformat.js" type="text/javascript"></script>
</#if> </#if>
<script src="js/base64.min.js" type="text/javascript"></script> <script src="js/base64.min.js" type="text/javascript"></script>
<style>
#htmlPreviewFrame {
width: 100%;
min-height: 65vh;
border: 0;
background: #fff;
}
#htmlSource {
min-height: 65vh;
overflow: auto;
border: 0;
background: #fff;
white-space: pre-wrap;
word-break: break-word;
}
</style>
</head> </head>
<body> <body>
<input hidden id="textData" value="${textData}"/> <input hidden id="textData" value="${textData}"/>
@@ -41,7 +25,7 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<h4 class="panel-title"> <h4 class="panel-title">
<strong><font color="red"><input class="GLOkBtn" type="button" value="在沙箱中运行html" onclick="loadXmlData();" /></font></strong> <strong><font color="red"><input class="GLOkBtn" type="button" value="运行html" onclick="loadXmlData();" /></font></strong>
<a data-toggle="collapse" data-parent="#accordion" onclick="loadText();"> <a data-toggle="collapse" data-parent="#accordion" onclick="loadText();">
${file.name} ${file.name}
</a> </a>
@@ -56,42 +40,57 @@
// 将Freemarker的布尔值传递给JavaScript // 将Freemarker的布尔值传递给JavaScript
var scriptjs = ${scriptjs?c}; // ?c 将布尔值转换为字符串true/false var scriptjs = ${scriptjs?c}; // ?c 将布尔值转换为字符串true/false
function decodePreviewText() {
var escapedText = Base64.decode($("#textData").val());
var decoder = document.createElement("textarea");
decoder.innerHTML = escapedText;
return decoder.value;
}
function replacePreviewContent(element) {
var container = document.getElementById("text");
while (container.firstChild) {
container.removeChild(container.firstChild);
}
container.appendChild(element);
}
/** /**
*加载普通文本 *加载普通文本
*/ */
function loadText() { function loadText() {
var source = document.createElement("pre"); var base64data = $("#textData").val()
source.id = "htmlSource"; var div = document.getElementById("text");
source.textContent = decodePreviewText(); div.innerHTML = ""; //
replacePreviewContent(source); var textData = Base64.decode(base64data);
textData = htmlttt(textData,1);
var textPreData = "<xmp style='background-color: #FFFFFF;overflow-y: scroll;border:none'>" + textData + "</xmp>";
$("#text").append(textPreData);
}
function htmlttt (str,txt){
var s = "";
if(str.length == 0) return "";
s = str.replace(/&amp;/gi,"&");
s = s.replace(/&lt;/gi,"<");
s = s.replace(/&gt;/gi,">");
s = s.replace(/&nbsp;/gi," ");
s = s.replace(/&#39;/gi,"\'");
s = s.replace(/&quot;/gi,"\"");
s = s.replace(/javascript/g,"javascript ");
if (txt === 2){
s = s.replace(/<script/gi, "&lt;script ");
s = s.replace(/javascript/g,"javascript ");
s = s.replace(/<\/script/gi, "&lt;/script ");
s = s.replace(/<iframe/gi, "&lt;iframe ");
s = s.replace(/<\/iframe/gi, "&lt;/iframe ");
s = s.replace(/confirm/gi, "c&onfirm");
s = s.replace(/alert/gi, "a&lert");
s = s.replace(/eval/gi, "e&val");
}
return s;
} }
/** /**
*加载运行 *加载运行
*/ */
function loadXmlData() { function loadXmlData() {
var frame = document.createElement("iframe"); var base64data = $("#textData").val();
frame.id = "htmlPreviewFrame"; var textData = Base64.decode(base64data);
frame.title = "HTML sandbox preview";
frame.setAttribute("sandbox", scriptjs ? "allow-scripts" : ""); // 直接使用JavaScript变量进行判断
frame.setAttribute("referrerpolicy", "no-referrer"); if (scriptjs) {
frame.srcdoc = decodePreviewText(); textData = htmlttt(textData, 1);
replacePreviewContent(frame); } else {
textData = htmlttt(textData, 2);
}
$('#text').html(textData);
} }
/** /**

View File

@@ -369,8 +369,8 @@
$("#deleteCaptchaConfirmBtn").click(function() { $("#deleteCaptchaConfirmBtn").click(function() {
var fileName = $("#deleteCaptchaFileName").val(); var fileName = $("#deleteCaptchaFileName").val();
var deleteCaptchaText = $("#deleteCaptchaText").val(); var deleteCaptchaText = $("#deleteCaptchaText").val();
$.post('${baseUrl}deleteFile', {fileName: fileName, password: deleteCaptchaText}, function(data){ $.get('${baseUrl}deleteFile?fileName=' + fileName +'&password=' + deleteCaptchaText, function(data){
if (!data.success) { if ("删除文件失败,密码错误!" === data.msg) {
alert(data.msg); alert(data.msg);
} else { } else {
$('#table').bootstrapTable("refresh", {}); $('#table').bootstrapTable("refresh", {});
@@ -392,16 +392,11 @@
function deleteFile(fileName, isFolder) { function deleteFile(fileName, isFolder) {
var message = isFolder ? '你确定要删除这个文件夹吗包含所有子文件' : '你确定要删除这个文件吗'; var message = isFolder ? '你确定要删除这个文件夹吗包含所有子文件' : '你确定要删除这个文件吗';
if (window.confirm(message)) { if (window.confirm(message)) {
var password = prompt("请输入文件删除密码"); password = prompt("请输入默认密码:123456");
if (password === null) {
return false;
}
$.ajax({ $.ajax({
url: '${baseUrl}deleteFile', url: '${baseUrl}deleteFile?fileName=' + fileName +'&password='+password,
type: 'POST',
data: {fileName: fileName, password: password},
success: function (data) { success: function (data) {
if (!data.success) { if ("删除文件失败,密码错误!" === data.msg) {
alert(data.msg); alert(data.msg);
} else { } else {
$("#table").bootstrapTable("refresh", {}); $("#table").bootstrapTable("refresh", {});

View File

@@ -41,10 +41,10 @@
你可以先看最新版本的升级重点,再顺着时间轴继续了解历史版本细节。 你可以先看最新版本的升级重点,再顺着时间轴继续了解历史版本细节。
</p> </p>
<div class="release-badge-row"> <div class="release-badge-row">
<span class="tag highlight">最新版本 v5.0.2</span> <span class="tag highlight">最新版本 v5.0.0</span>
<span class="tag brand">发布日期 2026-08-14</span> <span class="tag brand">发布日期 2026-04-14</span>
<span class="tag warn">JDK 21+ 强制要求</span> <span class="tag warn">JDK 21+ 强制要求</span>
<span class="tag">安全补丁 / HTML、文件删除、PDF 转图修复</span> <span class="tag">压缩包工作区预览 / PDF 默认模式</span>
</div> </div>
</div> </div>
</section> </section>
@@ -52,92 +52,11 @@
<section class="release-section"> <section class="release-section">
<div class="timeline-year">2026</div> <div class="timeline-year">2026</div>
<div class="timeline-list"> <div class="timeline-list">
<article class="release-card">
<h3>v5.0.2</h3>
<div class="release-meta">
<span class="tag brand">2026-08-14</span>
<span class="tag highlight">最新稳定版本</span>
<span class="tag warn">建议尽快升级</span>
</div>
<div class="release-columns">
<div class="release-group">
<h4>安全修复</h4>
<ul class="release-list">
<li>HTML 文件改在不具有同源权限的 iframe 沙箱中预览,并默认禁用 JavaScript。</li>
<li>文件删除接口默认禁用,改用 POST并要求显式配置密码后进行精确比较。</li>
</ul>
</div>
<div class="release-group">
<h4>修复</h4>
<ul class="release-list">
<li>刷新 ImageIO 插件,修复 PDF 转图片预览时 JBIG2 等图像读取器未被发现导致的图片丢失。</li>
</ul>
</div>
<div class="release-group">
<h4>配置调整</h4>
<ul class="release-list">
<li><code>delete.password</code> 默认改为 <code>false</code>。</li>
<li><code>kk.scriptjs</code> 默认改为 <code>false</code>,启用后仍保持沙箱隔离。</li>
</ul>
</div>
<div class="release-group">
<h4>升级重点</h4>
<ul class="release-list">
<li>建议所有 v5.0.1 及更早版本用户尽快升级。</li>
<li>继续要求 JDK 21 及以上,现有配置可直接沿用。</li>
<li>如需删除功能,请配置独立强密码,并将调用方式改为 POST。</li>
</ul>
</div>
</div>
</article>
<article class="release-card">
<h3>v5.0.1</h3>
<div class="release-meta">
<span class="tag brand">2026-07-13</span>
<span class="tag">上一补丁版本</span>
<span class="tag warn">建议尽快升级</span>
</div>
<div class="release-columns">
<div class="release-group">
<h4>安全修复</h4>
<ul class="release-list">
<li>修复 <code>/addTask</code> 未覆盖信任主机和本地目录过滤导致的 SSRF 风险。</li>
<li>修复 <code>/listFiles</code> 可越出演示目录导致的路径遍历和目录信息泄露。</li>
</ul>
</div>
<div class="release-group">
<h4>修复</h4>
<ul class="release-list">
<li>修复 PDF 跨域、页码、高亮、打印、打印水印及反向代理路径问题。</li>
<li>修复 Redis 多种运行模式的配置兼容问题。</li>
<li>修复 HTTP 错误处理、共享 Client 生命周期和 xlsx 数据校验解析问题。</li>
</ul>
</div>
<div class="release-group">
<h4>优化</h4>
<ul class="release-list">
<li>大型 xlsx 文件使用 Web Worker 解析,并保留主线程自动回退。</li>
<li>新增 <code>pdf.sidebar.open</code>,支持配置 PDF 默认侧栏状态。</li>
<li>Maven CI 增加 Linux、Windows、macOS 构建验证。</li>
</ul>
</div>
<div class="release-group">
<h4>升级重点</h4>
<ul class="release-list">
<li>建议所有 v5.0.0 及更早版本用户尽快升级。</li>
<li>继续要求 JDK 21 及以上。</li>
<li>现有 v5.0.0 配置可直接沿用。</li>
</ul>
</div>
</div>
</article>
<article class="release-card"> <article class="release-card">
<h3>v5.0.0</h3> <h3>v5.0.0</h3>
<div class="release-meta"> <div class="release-meta">
<span class="tag brand">2026-04-14</span> <span class="tag brand">2026-04-14</span>
<span class="tag">5.0 功能版本</span> <span class="tag highlight">最新稳定版本</span>
<span class="tag warn">升级需 JDK 21+</span> <span class="tag warn">升级需 JDK 21+</span>
</div> </div>
<div class="release-columns"> <div class="release-columns">

View File

@@ -1,51 +0,0 @@
package cn.keking.service;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import javax.imageio.spi.IIORegistry;
import javax.imageio.spi.ImageReaderSpi;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PdfToJpgServiceTests {
@Test
void shouldRediscoverJbig2ReaderAfterInitialRegistryMiss() {
IIORegistry registry = IIORegistry.getDefaultInstance();
List<ImageReaderSpi> providers = findJbig2Providers(registry);
assertFalse(providers.isEmpty(), "jbig2-imageio must be present on the test class path");
try {
providers.forEach(registry::deregisterServiceProvider);
assertFalse(hasJbig2Reader());
PdfToJpgService.refreshImageIoPlugins();
assertTrue(hasJbig2Reader());
} finally {
providers.forEach(registry::registerServiceProvider);
}
}
private static List<ImageReaderSpi> findJbig2Providers(IIORegistry registry) {
Iterator<ImageReaderSpi> providers = registry.getServiceProviders(
ImageReaderSpi.class,
provider -> Arrays.stream(((ImageReaderSpi) provider).getFormatNames())
.anyMatch("JBIG2"::equalsIgnoreCase),
true
);
List<ImageReaderSpi> result = new ArrayList<>();
providers.forEachRemaining(result::add);
return result;
}
private static boolean hasJbig2Reader() {
return ImageIO.getImageReadersByFormatName("JBIG2").hasNext();
}
}

View File

@@ -1,38 +0,0 @@
package cn.keking.web;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class HtmlPreviewSandboxTests {
@Test
void shouldRenderHtmlOnlyInsideAnOpaqueOriginSandbox() throws IOException {
String template = readResource("web/code.ftl");
assertTrue(template.contains("frame.setAttribute(\"sandbox\", scriptjs ? \"allow-scripts\" : \"\")"));
assertTrue(template.contains("frame.srcdoc = decodePreviewText()"));
assertFalse(template.contains("allow-same-origin"));
assertFalse(template.contains("$('#text').html(textData)"));
assertFalse(template.contains("function htmlttt"));
}
@Test
void shouldDisplaySourceAsTextAndDisableScriptsByDefault() throws IOException {
String template = readResource("web/code.ftl");
String properties = readResource("application.properties");
assertTrue(template.contains("source.textContent = decodePreviewText()"));
assertTrue(properties.contains("kk.scriptjs = false"));
}
private String readResource(String path) throws IOException {
ClassPathResource resource = new ClassPathResource(path);
return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
}
}

View File

@@ -1,113 +0,0 @@
package cn.keking.web.controller;
import cn.keking.config.ConfigConstants;
import cn.keking.model.ReturnResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.codec.binary.Base64;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FileControllerDeleteSecurityTests {
@TempDir
Path tempDir;
private String originalFileDir;
private String originalPassword;
private Boolean originalDeleteCaptcha;
@BeforeEach
void configureDemoDirectory() throws IOException {
originalFileDir = ConfigConstants.getFileDir();
originalPassword = ConfigConstants.getPassword();
originalDeleteCaptcha = ConfigConstants.getDeleteCaptcha();
Files.createDirectory(tempDir.resolve("demo"));
ConfigConstants.setFileDirValue(tempDir.toString());
ConfigConstants.setDeleteCaptchaValue(false);
}
@AfterEach
void restoreConfiguration() {
ConfigConstants.setFileDirValue(originalFileDir);
ConfigConstants.setPasswordValue(originalPassword);
ConfigConstants.setDeleteCaptchaValue(originalDeleteCaptcha);
}
@Test
void shouldDisableDeletionWhenNoPasswordIsConfigured() throws IOException {
ConfigConstants.setPasswordValue("false");
Path victim = Files.writeString(tempDir.resolve("demo/victim.txt"), "keep");
FileController controller = new FileController();
ReturnResponse<Object> response = controller.deleteFile(
new MockHttpServletRequest(), encodeFileName("victim.txt"), "false");
assertTrue(response.isFailure());
assertTrue(Files.exists(victim));
}
@Test
void shouldRequireAnExactCaseSensitivePassword() throws IOException {
ConfigConstants.setPasswordValue("Strong-Delete-Password");
Path victim = Files.writeString(tempDir.resolve("demo/victim.txt"), "delete me");
FileController controller = new FileController();
ReturnResponse<Object> wrongCase = controller.deleteFile(
new MockHttpServletRequest(), encodeFileName("victim.txt"), "strong-delete-password");
assertTrue(wrongCase.isFailure());
assertTrue(Files.exists(victim));
ReturnResponse<Object> correct = controller.deleteFile(
new MockHttpServletRequest(), encodeFileName("victim.txt"), "Strong-Delete-Password");
assertTrue(correct.isSuccess());
assertFalse(Files.exists(victim));
}
@Test
void shouldExposeDeletionOnlyAsPost() throws NoSuchMethodException {
Method method = FileController.class.getMethod(
"deleteFile", HttpServletRequest.class, String.class, String.class);
assertNotNull(method.getAnnotation(PostMapping.class));
assertNull(method.getAnnotation(GetMapping.class));
}
@Test
void shouldKeepDeletionDisabledAndCredentialsOutOfUrlsByDefault() throws IOException {
String properties = readResource("application.properties");
String template = readResource("web/main/index.ftl");
assertTrue(properties.contains("delete.password = ${KK_DELETE_PASSWORD:false}"));
assertTrue(template.contains("type: 'POST'"));
assertTrue(template.contains("$.post('${baseUrl}deleteFile'"));
assertFalse(template.contains("deleteFile?"));
assertFalse(template.contains("默认密码:123456"));
}
private String encodeFileName(String fileName) {
String value = "file://localhost/" + fileName;
return Base64.encodeBase64String(value.getBytes(StandardCharsets.UTF_8));
}
private String readResource(String path) throws IOException {
ClassPathResource resource = new ClassPathResource(path);
return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
}
}

View File

@@ -1,98 +0,0 @@
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<String, Object> result = controller.getFiles("..", "", 0, 20, null, null);
assertEquals("非法目录路径", result.get("error"));
assertTrue(((List<?>) result.get("data")).isEmpty());
}
}