diff --git a/server/src/main/config/application.properties b/server/src/main/config/application.properties index eda3b4b6..3e3913bc 100644 --- a/server/src/main/config/application.properties +++ b/server/src/main/config/application.properties @@ -408,8 +408,9 @@ home.pagesize = ${DEFAULT_HOME_PAGSIZE:20} # 启用后删除文件需要输入验证码,防止误删 delete.captcha = ${KK_DELETE_CAPTCHA:false} -# 删除文件密码,默认为123456 -delete.password = ${KK_DELETE_PASSWORD:123456} +# 删除文件密码,默认为false(禁用删除接口) +# 如需启用删除功能,请通过环境变量或外部配置设置独立的强密码 +delete.password = ${KK_DELETE_PASSWORD:false} # 是否删除转换后的源文件,默认为true(删除) # 启用可节约磁盘空间,但会丢失原始文件 diff --git a/server/src/main/config/test.properties b/server/src/main/config/test.properties index 1d469df3..650701e0 100644 --- a/server/src/main/config/test.properties +++ b/server/src/main/config/test.properties @@ -405,8 +405,8 @@ home.pagesize = ${DEFAULT_HOME_PAGSIZE:20} # 启用后删除文件需要输入验证码,防止误删 delete.captcha = ${KK_DELETE_CAPTCHA:false} -# 删除文件密码,默认为123456 -delete.password = ${KK_DELETE_PASSWORD:123456} +# 删除文件密码,默认为false(禁用删除接口) +delete.password = ${KK_DELETE_PASSWORD:false} # 是否删除转换后的源文件,默认为true(删除) # 启用可节约磁盘空间,但会丢失原始文件 diff --git a/server/src/main/java/cn/keking/config/ConfigConstants.java b/server/src/main/java/cn/keking/config/ConfigConstants.java index 48cce78a..686a4cd5 100644 --- a/server/src/main/java/cn/keking/config/ConfigConstants.java +++ b/server/src/main/java/cn/keking/config/ConfigConstants.java @@ -31,7 +31,7 @@ public class ConfigConstants { // ================================================== public static final String DEFAULT_VALUE = "default"; 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_ENABLE_REFRECSHSCHEDULE = "5"; public static final String DEFAULT_IS_JAVASCRIPT = "false"; @@ -664,7 +664,7 @@ public class ConfigConstants { public void setSize(String size) { setSizeValue(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 static void setPasswordValue(String password) { ConfigConstants.password = password; } diff --git a/server/src/main/java/cn/keking/web/controller/FileController.java b/server/src/main/java/cn/keking/web/controller/FileController.java index 793abe6d..43c2586b 100644 --- a/server/src/main/java/cn/keking/web/controller/FileController.java +++ b/server/src/main/java/cn/keking/web/controller/FileController.java @@ -11,6 +11,7 @@ import cn.keking.utils.WebUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; import org.springframework.util.StreamUtils; import org.springframework.web.bind.annotation.GetMapping; 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.Paths; import java.nio.file.attribute.BasicFileAttributes; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.*; import static cn.keking.utils.CaptchaUtil.CAPTCHA_CODE; @@ -218,7 +221,7 @@ public class FileController { } } - @GetMapping("/deleteFile") + @PostMapping("/deleteFile") public ReturnResponse deleteFile(HttpServletRequest request, String fileName, String password) { ReturnResponse checkResult = this.deleteFileCheck(request, fileName, password); if (checkResult.isFailure()) { @@ -775,11 +778,22 @@ public class FileController { return ReturnResponse.failure("密码 or 验证码为空,删除失败!"); } - String expectedPassword = ConfigConstants.getDeleteCaptcha() ? + boolean captchaEnabled = ConfigConstants.getDeleteCaptcha(); + String expectedPassword = captchaEnabled ? WebUtils.getSessionAttr(request, CAPTCHA_CODE) : 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); return ReturnResponse.failure("删除文件失败,密码错误!"); } diff --git a/server/src/main/resources/web/main/index.ftl b/server/src/main/resources/web/main/index.ftl index c50ac23d..57331f66 100644 --- a/server/src/main/resources/web/main/index.ftl +++ b/server/src/main/resources/web/main/index.ftl @@ -369,8 +369,8 @@ $("#deleteCaptchaConfirmBtn").click(function() { var fileName = $("#deleteCaptchaFileName").val(); var deleteCaptchaText = $("#deleteCaptchaText").val(); - $.get('${baseUrl}deleteFile?fileName=' + fileName +'&password=' + deleteCaptchaText, function(data){ - if ("删除文件失败,密码错误!" === data.msg) { + $.post('${baseUrl}deleteFile', {fileName: fileName, password: deleteCaptchaText}, function(data){ + if (!data.success) { alert(data.msg); } else { $('#table').bootstrapTable("refresh", {}); @@ -392,11 +392,16 @@ function deleteFile(fileName, isFolder) { var message = isFolder ? '你确定要删除这个文件夹吗?(包含所有子文件)' : '你确定要删除这个文件吗?'; if (window.confirm(message)) { - password = prompt("请输入默认密码:123456"); + var password = prompt("请输入文件删除密码"); + if (password === null) { + return false; + } $.ajax({ - url: '${baseUrl}deleteFile?fileName=' + fileName +'&password='+password, + url: '${baseUrl}deleteFile', + type: 'POST', + data: {fileName: fileName, password: password}, success: function (data) { - if ("删除文件失败,密码错误!" === data.msg) { + if (!data.success) { alert(data.msg); } else { $("#table").bootstrapTable("refresh", {}); diff --git a/server/src/test/java/cn/keking/web/controller/FileControllerDeleteSecurityTests.java b/server/src/test/java/cn/keking/web/controller/FileControllerDeleteSecurityTests.java new file mode 100644 index 00000000..5a9376d9 --- /dev/null +++ b/server/src/test/java/cn/keking/web/controller/FileControllerDeleteSecurityTests.java @@ -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 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 wrongCase = controller.deleteFile( + new MockHttpServletRequest(), encodeFileName("victim.txt"), "strong-delete-password"); + assertTrue(wrongCase.isFailure()); + assertTrue(Files.exists(victim)); + + ReturnResponse 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); + } +}