From 2f655a816beba1d5dd02d6c49e992f667b0392ad Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 30 Oct 2026 01:23:01 +0800 Subject: [PATCH] fix: 修复钱包充值退款权限与 IoT 固件上传校验 --- yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java | 30 +++++++++++++++++++++++++++--- yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletRechargeController.java | 2 ++ yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletRechargeControllerSecurityTest.java | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletRechargeControllerSecurityTest.java diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java index 218d5b0..4527bf6 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java @@ -22,9 +22,11 @@ import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; import com.google.common.annotations.VisibleForTesting; import jakarta.annotation.Resource; import lombok.SneakyThrows; +import org.apache.tika.mime.MimeTypes; import org.springframework.stereotype.Service; import java.util.List; +import java.util.Locale; import java.util.Set; import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; @@ -63,6 +65,14 @@ public class FileServiceImpl implements FileService { ); /** + * 允许上传的二进制文件后缀,例如说 IoT OTA 固件的 .bin + * + * 为什么不直接放行 application/octet-stream?它也是所有未知后缀、未知内容的识别结果,放行后等于不限制类型。 + * 所以只放行这里的后缀,并且上传内容也必须识别为 application/octet-stream(不能是可执行文件、脚本、HTML 等) + */ + private static final Set ALLOWED_BINARY_EXTENSIONS = Set.of("bin"); + + /** * 上传文件的前缀,是否包含日期(yyyyMMdd) * * 目的:按照日期,进行分目录 @@ -106,8 +116,10 @@ public class FileServiceImpl implements FileService { } type = FileTypeUtils.getMineType(content, name); String nameType = StrUtil.isNotEmpty(FileUtil.extName(name)) ? validateFileType(name) : null; - if (!ALLOWED_FILE_TYPES.contains(type) - || (FileTypeUtils.isImage(nameType) && ObjectUtil.notEqual(type, nameType))) { + if (!isAllowedFileType(type, name) + // 图片、二进制文件(例如说 .bin 固件):内容识别的类型,必须与后缀一致 + || ((FileTypeUtils.isImage(nameType) || isAllowedBinaryFile(nameType, name)) + && ObjectUtil.notEqual(type, nameType))) { throw exception(FILE_TYPE_NOT_ALLOWED); } // 1.2.2 处理 name 为空的情况 @@ -221,12 +233,24 @@ public class FileServiceImpl implements FileService { private String validateFileType(String name) { String type = FileTypeUtils.getMineType(name); - if (!ALLOWED_FILE_TYPES.contains(type)) { + if (!isAllowedFileType(type, name)) { throw exception(FILE_TYPE_NOT_ALLOWED); } return type; } + private static boolean isAllowedFileType(String type, String name) { + return ALLOWED_FILE_TYPES.contains(type) || isAllowedBinaryFile(type, name); + } + + private static boolean isAllowedBinaryFile(String type, String name) { + if (ObjectUtil.notEqual(type, MimeTypes.OCTET_STREAM)) { + return false; + } + String extension = FileUtil.extName(name); + return StrUtil.isNotEmpty(extension) && ALLOWED_BINARY_EXTENSIONS.contains(extension.toLowerCase(Locale.ROOT)); + } + @Override public FileDO getFile(Long id) { return validateFileExists(id); diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java index 081dfcd..1d5f087 100644 --- a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java +++ b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java @@ -322,6 +322,79 @@ public class FileServiceImplTest extends BaseDbUnitTest { } @Test + public void testCreateFile_success_bin() throws Exception { + for (String name : new String[]{"固件.bin", "FIRMWARE.BIN", "firmware.Bin"}) { + // 准备参数:IoT OTA 固件,Tika 无法识别的二进制内容 + byte[] content = buildFirmwareContent(); + // mock 方法 + FileClient client = mock(FileClient.class); + when(fileConfigService.getMasterFileClient()).thenReturn(client); + String url = randomString(); + when(client.upload(same(content), anyString(), eq("application/octet-stream"))).thenReturn(url); + + // 调用:不信任客户端传入的 type + String result = fileService.createFile(content, name, "iot/ota", "image/png"); + // 断言 + assertEquals(url, result); + FileDO file = fileMapper.selectOne(FileDO::getUrl, url); + assertEquals(name, file.getName()); + assertEquals("application/octet-stream", file.getType()); + assertEquals((long) content.length, file.getSize()); + } + } + + @Test + public void testCreateFile_typeNotAllowed_binContent() { + byte[] elf = buildFirmwareContent(); + System.arraycopy(new byte[]{0x7F, 'E', 'L', 'F', 2, 1, 1}, 0, elf, 0, 7); + elf[16] = 2; // e_type = ET_EXEC + byte[] exe = buildFirmwareContent(); + exe[0] = 'M'; + exe[1] = 'Z'; + for (byte[] content : new byte[][]{elf, exe, + "#!/bin/sh\nrm -rf /tmp/test\n".getBytes(StandardCharsets.UTF_8), + "".getBytes(StandardCharsets.UTF_8), + "".getBytes(StandardCharsets.UTF_8), + ResourceUtil.readBytes("file/erweima.jpg")}) { + // 调用,并断言异常:.bin 的内容,必须是无法识别为其他类型的二进制 + assertServiceException(() -> fileService.createFile(content, "固件.bin", null, "application/octet-stream"), + FILE_TYPE_NOT_ALLOWED); + // 断言未上传文件、未保存记录 + verifyNoInteractions(fileConfigService); + assertEquals(0L, fileMapper.selectCount()); + } + } + + @Test + public void testCreateFile_typeNotAllowed_binaryOtherExtension() { + for (String name : new String[]{"固件.so", "固件.dump", "固件.unknown", "固件"}) { + // 准备参数:同样识别为 application/octet-stream 的其它后缀,不放行 + byte[] content = buildFirmwareContent(); + + // 调用,并断言异常 + assertServiceException(() -> fileService.createFile(content, name, null, "application/octet-stream"), + FILE_TYPE_NOT_ALLOWED); + // 断言未上传文件、未保存记录 + verifyNoInteractions(fileConfigService); + assertEquals(0L, fileMapper.selectCount()); + } + } + + /** + * 构造固件内容:类似 ARM Cortex-M 的向量表开头,包含 0x00 等不可打印字节,Tika 无法识别 + */ + private static byte[] buildFirmwareContent() { + byte[] content = new byte[256]; + byte[] header = new byte[]{0x00, 0x50, 0x00, 0x20, (byte) 0xC1, 0x01, 0x00, 0x08, + (byte) 0xC9, 0x01, 0x00, 0x08, (byte) 0xCB, 0x01, 0x00, 0x08}; + System.arraycopy(header, 0, content, 0, header.length); + for (int i = header.length; i < content.length; i++) { + content[i] = (byte) (i * 7); + } + return content; + } + + @Test public void testCreateFile_fileIsEmpty() { // 准备参数 byte[] content = new byte[0]; @@ -337,7 +410,7 @@ public class FileServiceImplTest extends BaseDbUnitTest { for (String extension : new String[]{"jpg", "JPEG", "jfif", "png", "gif", "webp", "bmp", "ico", "tiff", "avif", "heic", "heif", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "csv", "md", "zip", "rar", "mp3", "mp4", "svg", "html", "htm", "xml", "markdown", "eml", "msg", "epub", - "json", "opus", "ogg", "m4a", "webm", "amr", "wma", "wmv", "flv", "mkv"}) { + "json", "opus", "ogg", "m4a", "webm", "amr", "wma", "wmv", "flv", "mkv", "bin", "BIN"}) { // 准备参数 String name = "测试文件." + extension; // mock 方法 @@ -360,7 +433,8 @@ public class FileServiceImplTest extends BaseDbUnitTest { @Test public void testPresignPutUrl_typeNotAllowed() { // 准备参数 - for (String name : new String[]{"test.php", "test.jsp", "test.js", "test.exe", "test.unknown", "test"}) { + for (String name : new String[]{"test.php", "test.jsp", "test.js", "test.exe", "test.unknown", "test", + "test.so", "test.dump", "test.sh", "test.bin.exe"}) { // 调用,并断言异常 assertServiceException(() -> fileService.presignPutUrl(name, null), FILE_TYPE_NOT_ALLOWED); // 校验调用 @@ -491,6 +565,47 @@ public class FileServiceImplTest extends BaseDbUnitTest { } @Test + public void testCreateFileByPresignedPath_success_bin() { + // 准备参数:IoT OTA 固件,后缀大小写不敏感 + FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> { + o.setPath("iot/ota/firmware.bin"); + o.setName("FIRMWARE.BIN"); + o.setUrl("https://www.iocoder.cn/iot/ota/firmware.bin"); + o.setType("text/html"); // 不信任客户端传入的 type + }); + + // 调用 + Long fileId = fileService.createFile(reqVO); + + // 断言 + FileDO file = fileMapper.selectById(fileId); + assertEquals("iot/ota/firmware.bin", file.getPath()); + assertEquals("FIRMWARE.BIN", file.getName()); + assertEquals("application/octet-stream", file.getType()); + } + + @Test + public void testCreateFileByPresignedPath_typeNotAllowed_bin() { + for (String[] file : new String[][]{{"firmware.bin", "iot/firmware.exe"}, {"firmware.bin", "iot/firmware.so"}, + {"firmware.so", "iot/firmware.so"}, {"firmware.exe", "iot/firmware.bin"}, + {"firmware.bin", "iot/firmware.html"}, {"firmware", "iot/firmware.bin"}}) { + // 准备参数 + String name = file[0]; + String path = file[1]; + FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> { + o.setName(name); + o.setPath(path); + o.setType("application/octet-stream"); + }); + + // 调用,并断言异常 + assertServiceException(() -> fileService.createFile(reqVO), FILE_TYPE_NOT_ALLOWED); + // 断言未保存记录 + assertEquals(0L, fileMapper.selectCount()); + } + } + + @Test public void testCreateFileByPresignedPath_nameInvalid() { // 准备参数 FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> { diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletRechargeController.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletRechargeController.java index 2626323..6d35a9b 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletRechargeController.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletRechargeController.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -40,6 +41,7 @@ public class PayWalletRechargeController { @PostMapping("/refund") @Operation(summary = "发起钱包充值退款") @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pay:wallet:update-balance')") // 退款会扣减用户钱包余额,复用「用户余额修改」权限 public CommonResult refundWalletRecharge(@RequestParam("id") Long id) { walletRechargeService.refundWalletRecharge(id, getClientIP()); return success(true); diff --git a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletRechargeControllerSecurityTest.java b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletRechargeControllerSecurityTest.java new file mode 100644 index 0000000..d4a7af9 --- /dev/null +++ b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletRechargeControllerSecurityTest.java @@ -0,0 +1,163 @@ +package cn.iocoder.yudao.module.pay.controller.admin.wallet; + +import cn.iocoder.yudao.framework.common.biz.system.permission.PermissionCommonApi; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkServiceImpl; +import cn.iocoder.yudao.module.pay.api.notify.dto.PayOrderNotifyReqDTO; +import cn.iocoder.yudao.module.pay.service.wallet.PayWalletRechargeService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.aop.support.AopUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link PayWalletRechargeController} 的方法级权限测试 + * + * 通过真实的 Spring Security 方法拦截({@link EnableMethodSecurity})+ 真实的 {@code @ss} 权限判断, + * 校验发起钱包充值退款需要 pay:wallet:update-balance 权限,拒绝时不会调用 Service + * + * @author 芋道源码 + */ +@SpringJUnitConfig(PayWalletRechargeControllerSecurityTest.Config.class) +public class PayWalletRechargeControllerSecurityTest { + + private static final String PERMISSION = "pay:wallet:update-balance"; + private static final Long USER_ID = 1L; + private static final Long RECHARGE_ID = 1024L; + + @Resource + private PayWalletRechargeController controller; + @Resource + private PayWalletRechargeService walletRechargeService; + @Resource + private PermissionCommonApi permissionApi; + + @BeforeEach + public void setUp() { + reset(walletRechargeService, permissionApi); + SecurityContextHolder.clearContext(); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testControllerIsProxied() { + // 确认测试走的是方法安全代理,而不是裸对象 + assertTrue(AopUtils.isAopProxy(controller)); + } + + @Test + public void testRefundWalletRecharge_hasPermission() { + // mock 方法 + loginAdmin(); + when(permissionApi.hasAnyPermissions(eq(USER_ID), eq(PERMISSION))).thenReturn(true); + + // 调用 + assertTrue(controller.refundWalletRecharge(RECHARGE_ID).getData()); + // 断言 + verify(permissionApi).hasAnyPermissions(eq(USER_ID), eq(PERMISSION)); + verify(walletRechargeService).refundWalletRecharge(eq(RECHARGE_ID), isNull()); + } + + @Test + public void testRefundWalletRecharge_noPermission() { + // mock 方法 + loginAdmin(); + when(permissionApi.hasAnyPermissions(anyLong(), any(String[].class))).thenReturn(false); + + // 调用,并断言 + assertThrows(AccessDeniedException.class, () -> controller.refundWalletRecharge(RECHARGE_ID)); + verify(permissionApi).hasAnyPermissions(eq(USER_ID), eq(PERMISSION)); + verifyNoInteractions(walletRechargeService); + } + + @Test + public void testRefundWalletRecharge_anonymous() { + // 未登录:Web 过滤链会放入匿名用户 + SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken("key", "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))); + + // 调用,并断言 + assertThrows(AccessDeniedException.class, () -> controller.refundWalletRecharge(RECHARGE_ID)); + verifyNoInteractions(permissionApi, walletRechargeService); + } + + @Test + public void testRefundWalletRecharge_noAuthentication() { + // 没有任何认证信息,同样拒绝:取决于 Spring Security 是否读取认证信息,抛出拒绝访问或认证缺失 + Exception ex = assertThrows(Exception.class, () -> controller.refundWalletRecharge(RECHARGE_ID)); + assertTrue(ex instanceof AccessDeniedException || ex instanceof AuthenticationCredentialsNotFoundException, + "非预期的异常:" + ex); + verifyNoInteractions(permissionApi, walletRechargeService); + } + + @Test + public void testUpdateWalletRechargerPaid_callbackNotRestricted() { + // 支付回调:仍由 @PermitAll + 内部校验,不受后台权限约束 + PayOrderNotifyReqDTO reqDTO = PayOrderNotifyReqDTO.builder().merchantOrderId("1").payOrderId(2L).build(); + + // 调用 + assertTrue(controller.updateWalletRechargerPaid(reqDTO).getData()); + // 断言 + verify(walletRechargeService).updateWalletRechargerPaid(eq(1L), eq(2L)); + verifyNoInteractions(permissionApi); + } + + private static void loginAdmin() { + LoginUser loginUser = new LoginUser(); + loginUser.setId(USER_ID); + loginUser.setUserType(UserTypeEnum.ADMIN.getValue()); + loginUser.setTenantId(1L); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList())); + } + + @Configuration + @EnableMethodSecurity(securedEnabled = true) + public static class Config { + + @Bean + public PayWalletRechargeController payWalletRechargeController() { + return new PayWalletRechargeController(); + } + + @Bean + public PayWalletRechargeService payWalletRechargeService() { + return mock(PayWalletRechargeService.class); + } + + @Bean + public PermissionCommonApi permissionCommonApi() { + return mock(PermissionCommonApi.class); + } + + @Bean("ss") + public SecurityFrameworkService securityFrameworkService(PermissionCommonApi permissionApi) { + return new SecurityFrameworkServiceImpl(permissionApi); + } + + } + +} -- libgit2 0.21.4