fix: secure demo file deletion

This commit is contained in:
kl
2026-08-14 11:12:50 +08:00
parent 9c3fd82076
commit f852bf844b
6 changed files with 148 additions and 15 deletions

View File

@@ -408,8 +408,9 @@ home.pagesize = ${DEFAULT_HOME_PAGSIZE:20}
# 启用后删除文件需要输入验证码防止误删 # 启用后删除文件需要输入验证码防止误删
delete.captcha = ${KK_DELETE_CAPTCHA:false} delete.captcha = ${KK_DELETE_CAPTCHA:false}
# 删除文件密码默认为123456 # 删除文件密码默认为false禁用删除接口
delete.password = ${KK_DELETE_PASSWORD:123456} # 如需启用删除功能请通过环境变量或外部配置设置独立的强密码
delete.password = ${KK_DELETE_PASSWORD:false}
# 是否删除转换后的源文件默认为true删除 # 是否删除转换后的源文件默认为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}
# 删除文件密码默认为123456 # 删除文件密码默认为false禁用删除接口
delete.password = ${KK_DELETE_PASSWORD:123456} delete.password = ${KK_DELETE_PASSWORD:false}
# 是否删除转换后的源文件默认为true删除 # 是否删除转换后的源文件默认为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 = "123456"; public static final String DEFAULT_PASSWORD = "false";
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:123456}") @Value("${delete.password:false}")
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

@@ -11,6 +11,7 @@ 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;
@@ -33,6 +34,8 @@ 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;
@@ -218,7 +221,7 @@ public class FileController {
} }
} }
@GetMapping("/deleteFile") @PostMapping("/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()) {
@@ -775,11 +778,22 @@ public class FileController {
return ReturnResponse.failure("密码 or 验证码为空,删除失败!"); return ReturnResponse.failure("密码 or 验证码为空,删除失败!");
} }
String expectedPassword = ConfigConstants.getDeleteCaptcha() ? boolean captchaEnabled = ConfigConstants.getDeleteCaptcha();
String expectedPassword = captchaEnabled ?
WebUtils.getSessionAttr(request, CAPTCHA_CODE) : WebUtils.getSessionAttr(request, CAPTCHA_CODE) :
ConfigConstants.getPassword(); ConfigConstants.getPassword();
if (!password.equalsIgnoreCase(expectedPassword)) { if (!captchaEnabled && (!StringUtils.hasText(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

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

View File

@@ -0,0 +1,113 @@
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);
}
}