Commit 2f655a816beba1d5dd02d6c49e992f667b0392ad

Authored by YunaiV
1 parent 19829c6c

fix: 修复钱包充值退款权限与 IoT 固件上传校验

- 钱包充值退款复用用户余额修改权限,阻止无权限用户发起退款(#1328)
- 允许 .bin 固件上传、预签名及直传登记,保留未知后缀和内容类型校验
- 补充方法权限拦截与固件上传回归用例

星球反馈:https://t.zsxq.com/bAppp
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;
22 22 import com.google.common.annotations.VisibleForTesting;
23 23 import jakarta.annotation.Resource;
24 24 import lombok.SneakyThrows;
  25 +import org.apache.tika.mime.MimeTypes;
25 26 import org.springframework.stereotype.Service;
26 27  
27 28 import java.util.List;
  29 +import java.util.Locale;
28 30 import java.util.Set;
29 31  
30 32 import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN;
... ... @@ -63,6 +65,14 @@ public class FileServiceImpl implements FileService {
63 65 );
64 66  
65 67 /**
  68 + * 允许上传的二进制文件后缀,例如说 IoT OTA 固件的 .bin
  69 + *
  70 + * 为什么不直接放行 application/octet-stream?它也是所有未知后缀、未知内容的识别结果,放行后等于不限制类型。
  71 + * 所以只放行这里的后缀,并且上传内容也必须识别为 application/octet-stream(不能是可执行文件、脚本、HTML 等)
  72 + */
  73 + private static final Set<String> ALLOWED_BINARY_EXTENSIONS = Set.of("bin");
  74 +
  75 + /**
66 76 * 上传文件的前缀,是否包含日期(yyyyMMdd)
67 77 *
68 78 * 目的:按照日期,进行分目录
... ... @@ -106,8 +116,10 @@ public class FileServiceImpl implements FileService {
106 116 }
107 117 type = FileTypeUtils.getMineType(content, name);
108 118 String nameType = StrUtil.isNotEmpty(FileUtil.extName(name)) ? validateFileType(name) : null;
109   - if (!ALLOWED_FILE_TYPES.contains(type)
110   - || (FileTypeUtils.isImage(nameType) && ObjectUtil.notEqual(type, nameType))) {
  119 + if (!isAllowedFileType(type, name)
  120 + // 图片、二进制文件(例如说 .bin 固件):内容识别的类型,必须与后缀一致
  121 + || ((FileTypeUtils.isImage(nameType) || isAllowedBinaryFile(nameType, name))
  122 + && ObjectUtil.notEqual(type, nameType))) {
111 123 throw exception(FILE_TYPE_NOT_ALLOWED);
112 124 }
113 125 // 1.2.2 处理 name 为空的情况
... ... @@ -221,12 +233,24 @@ public class FileServiceImpl implements FileService {
221 233  
222 234 private String validateFileType(String name) {
223 235 String type = FileTypeUtils.getMineType(name);
224   - if (!ALLOWED_FILE_TYPES.contains(type)) {
  236 + if (!isAllowedFileType(type, name)) {
225 237 throw exception(FILE_TYPE_NOT_ALLOWED);
226 238 }
227 239 return type;
228 240 }
229 241  
  242 + private static boolean isAllowedFileType(String type, String name) {
  243 + return ALLOWED_FILE_TYPES.contains(type) || isAllowedBinaryFile(type, name);
  244 + }
  245 +
  246 + private static boolean isAllowedBinaryFile(String type, String name) {
  247 + if (ObjectUtil.notEqual(type, MimeTypes.OCTET_STREAM)) {
  248 + return false;
  249 + }
  250 + String extension = FileUtil.extName(name);
  251 + return StrUtil.isNotEmpty(extension) && ALLOWED_BINARY_EXTENSIONS.contains(extension.toLowerCase(Locale.ROOT));
  252 + }
  253 +
230 254 @Override
231 255 public FileDO getFile(Long id) {
232 256 return validateFileExists(id);
... ...
yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java
... ... @@ -322,6 +322,79 @@ public class FileServiceImplTest extends BaseDbUnitTest {
322 322 }
323 323  
324 324 @Test
  325 + public void testCreateFile_success_bin() throws Exception {
  326 + for (String name : new String[]{"固件.bin", "FIRMWARE.BIN", "firmware.Bin"}) {
  327 + // 准备参数:IoT OTA 固件,Tika 无法识别的二进制内容
  328 + byte[] content = buildFirmwareContent();
  329 + // mock 方法
  330 + FileClient client = mock(FileClient.class);
  331 + when(fileConfigService.getMasterFileClient()).thenReturn(client);
  332 + String url = randomString();
  333 + when(client.upload(same(content), anyString(), eq("application/octet-stream"))).thenReturn(url);
  334 +
  335 + // 调用:不信任客户端传入的 type
  336 + String result = fileService.createFile(content, name, "iot/ota", "image/png");
  337 + // 断言
  338 + assertEquals(url, result);
  339 + FileDO file = fileMapper.selectOne(FileDO::getUrl, url);
  340 + assertEquals(name, file.getName());
  341 + assertEquals("application/octet-stream", file.getType());
  342 + assertEquals((long) content.length, file.getSize());
  343 + }
  344 + }
  345 +
  346 + @Test
  347 + public void testCreateFile_typeNotAllowed_binContent() {
  348 + byte[] elf = buildFirmwareContent();
  349 + System.arraycopy(new byte[]{0x7F, 'E', 'L', 'F', 2, 1, 1}, 0, elf, 0, 7);
  350 + elf[16] = 2; // e_type = ET_EXEC
  351 + byte[] exe = buildFirmwareContent();
  352 + exe[0] = 'M';
  353 + exe[1] = 'Z';
  354 + for (byte[] content : new byte[][]{elf, exe,
  355 + "#!/bin/sh\nrm -rf /tmp/test\n".getBytes(StandardCharsets.UTF_8),
  356 + "<html><script>alert(1)</script></html>".getBytes(StandardCharsets.UTF_8),
  357 + "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>".getBytes(StandardCharsets.UTF_8),
  358 + ResourceUtil.readBytes("file/erweima.jpg")}) {
  359 + // 调用,并断言异常:.bin 的内容,必须是无法识别为其他类型的二进制
  360 + assertServiceException(() -> fileService.createFile(content, "固件.bin", null, "application/octet-stream"),
  361 + FILE_TYPE_NOT_ALLOWED);
  362 + // 断言未上传文件、未保存记录
  363 + verifyNoInteractions(fileConfigService);
  364 + assertEquals(0L, fileMapper.selectCount());
  365 + }
  366 + }
  367 +
  368 + @Test
  369 + public void testCreateFile_typeNotAllowed_binaryOtherExtension() {
  370 + for (String name : new String[]{"固件.so", "固件.dump", "固件.unknown", "固件"}) {
  371 + // 准备参数:同样识别为 application/octet-stream 的其它后缀,不放行
  372 + byte[] content = buildFirmwareContent();
  373 +
  374 + // 调用,并断言异常
  375 + assertServiceException(() -> fileService.createFile(content, name, null, "application/octet-stream"),
  376 + FILE_TYPE_NOT_ALLOWED);
  377 + // 断言未上传文件、未保存记录
  378 + verifyNoInteractions(fileConfigService);
  379 + assertEquals(0L, fileMapper.selectCount());
  380 + }
  381 + }
  382 +
  383 + /**
  384 + * 构造固件内容:类似 ARM Cortex-M 的向量表开头,包含 0x00 等不可打印字节,Tika 无法识别
  385 + */
  386 + private static byte[] buildFirmwareContent() {
  387 + byte[] content = new byte[256];
  388 + byte[] header = new byte[]{0x00, 0x50, 0x00, 0x20, (byte) 0xC1, 0x01, 0x00, 0x08,
  389 + (byte) 0xC9, 0x01, 0x00, 0x08, (byte) 0xCB, 0x01, 0x00, 0x08};
  390 + System.arraycopy(header, 0, content, 0, header.length);
  391 + for (int i = header.length; i < content.length; i++) {
  392 + content[i] = (byte) (i * 7);
  393 + }
  394 + return content;
  395 + }
  396 +
  397 + @Test
325 398 public void testCreateFile_fileIsEmpty() {
326 399 // 准备参数
327 400 byte[] content = new byte[0];
... ... @@ -337,7 +410,7 @@ public class FileServiceImplTest extends BaseDbUnitTest {
337 410 for (String extension : new String[]{"jpg", "JPEG", "jfif", "png", "gif", "webp", "bmp", "ico", "tiff",
338 411 "avif", "heic", "heif", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "csv", "md",
339 412 "zip", "rar", "mp3", "mp4", "svg", "html", "htm", "xml", "markdown", "eml", "msg", "epub",
340   - "json", "opus", "ogg", "m4a", "webm", "amr", "wma", "wmv", "flv", "mkv"}) {
  413 + "json", "opus", "ogg", "m4a", "webm", "amr", "wma", "wmv", "flv", "mkv", "bin", "BIN"}) {
341 414 // 准备参数
342 415 String name = "测试文件." + extension;
343 416 // mock 方法
... ... @@ -360,7 +433,8 @@ public class FileServiceImplTest extends BaseDbUnitTest {
360 433 @Test
361 434 public void testPresignPutUrl_typeNotAllowed() {
362 435 // 准备参数
363   - for (String name : new String[]{"test.php", "test.jsp", "test.js", "test.exe", "test.unknown", "test"}) {
  436 + for (String name : new String[]{"test.php", "test.jsp", "test.js", "test.exe", "test.unknown", "test",
  437 + "test.so", "test.dump", "test.sh", "test.bin.exe"}) {
364 438 // 调用,并断言异常
365 439 assertServiceException(() -> fileService.presignPutUrl(name, null), FILE_TYPE_NOT_ALLOWED);
366 440 // 校验调用
... ... @@ -491,6 +565,47 @@ public class FileServiceImplTest extends BaseDbUnitTest {
491 565 }
492 566  
493 567 @Test
  568 + public void testCreateFileByPresignedPath_success_bin() {
  569 + // 准备参数:IoT OTA 固件,后缀大小写不敏感
  570 + FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> {
  571 + o.setPath("iot/ota/firmware.bin");
  572 + o.setName("FIRMWARE.BIN");
  573 + o.setUrl("https://www.iocoder.cn/iot/ota/firmware.bin");
  574 + o.setType("text/html"); // 不信任客户端传入的 type
  575 + });
  576 +
  577 + // 调用
  578 + Long fileId = fileService.createFile(reqVO);
  579 +
  580 + // 断言
  581 + FileDO file = fileMapper.selectById(fileId);
  582 + assertEquals("iot/ota/firmware.bin", file.getPath());
  583 + assertEquals("FIRMWARE.BIN", file.getName());
  584 + assertEquals("application/octet-stream", file.getType());
  585 + }
  586 +
  587 + @Test
  588 + public void testCreateFileByPresignedPath_typeNotAllowed_bin() {
  589 + for (String[] file : new String[][]{{"firmware.bin", "iot/firmware.exe"}, {"firmware.bin", "iot/firmware.so"},
  590 + {"firmware.so", "iot/firmware.so"}, {"firmware.exe", "iot/firmware.bin"},
  591 + {"firmware.bin", "iot/firmware.html"}, {"firmware", "iot/firmware.bin"}}) {
  592 + // 准备参数
  593 + String name = file[0];
  594 + String path = file[1];
  595 + FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> {
  596 + o.setName(name);
  597 + o.setPath(path);
  598 + o.setType("application/octet-stream");
  599 + });
  600 +
  601 + // 调用,并断言异常
  602 + assertServiceException(() -> fileService.createFile(reqVO), FILE_TYPE_NOT_ALLOWED);
  603 + // 断言未保存记录
  604 + assertEquals(0L, fileMapper.selectCount());
  605 + }
  606 + }
  607 +
  608 + @Test
494 609 public void testCreateFileByPresignedPath_nameInvalid() {
495 610 // 准备参数
496 611 FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> {
... ...
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;
8 8 import io.swagger.v3.oas.annotations.Parameter;
9 9 import io.swagger.v3.oas.annotations.tags.Tag;
10 10 import lombok.extern.slf4j.Slf4j;
  11 +import org.springframework.security.access.prepost.PreAuthorize;
11 12 import org.springframework.validation.annotation.Validated;
12 13 import org.springframework.web.bind.annotation.*;
13 14  
... ... @@ -40,6 +41,7 @@ public class PayWalletRechargeController {
40 41 @PostMapping("/refund")
41 42 @Operation(summary = "发起钱包充值退款")
42 43 @Parameter(name = "id", description = "编号", required = true, example = "1024")
  44 + @PreAuthorize("@ss.hasPermission('pay:wallet:update-balance')") // 退款会扣减用户钱包余额,复用「用户余额修改」权限
43 45 public CommonResult<Boolean> refundWalletRecharge(@RequestParam("id") Long id) {
44 46 walletRechargeService.refundWalletRecharge(id, getClientIP());
45 47 return success(true);
... ...
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletRechargeControllerSecurityTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.pay.controller.admin.wallet;
  2 +
  3 +import cn.iocoder.yudao.framework.common.biz.system.permission.PermissionCommonApi;
  4 +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
  5 +import cn.iocoder.yudao.framework.security.core.LoginUser;
  6 +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService;
  7 +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkServiceImpl;
  8 +import cn.iocoder.yudao.module.pay.api.notify.dto.PayOrderNotifyReqDTO;
  9 +import cn.iocoder.yudao.module.pay.service.wallet.PayWalletRechargeService;
  10 +import jakarta.annotation.Resource;
  11 +import org.junit.jupiter.api.AfterEach;
  12 +import org.junit.jupiter.api.BeforeEach;
  13 +import org.junit.jupiter.api.Test;
  14 +import org.springframework.aop.support.AopUtils;
  15 +import org.springframework.context.annotation.Bean;
  16 +import org.springframework.context.annotation.Configuration;
  17 +import org.springframework.security.access.AccessDeniedException;
  18 +import org.springframework.security.authentication.AnonymousAuthenticationToken;
  19 +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
  20 +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
  21 +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
  22 +import org.springframework.security.core.authority.AuthorityUtils;
  23 +import org.springframework.security.core.context.SecurityContextHolder;
  24 +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
  25 +
  26 +import java.util.Collections;
  27 +
  28 +import static org.junit.jupiter.api.Assertions.*;
  29 +import static org.mockito.ArgumentMatchers.*;
  30 +import static org.mockito.Mockito.*;
  31 +
  32 +/**
  33 + * {@link PayWalletRechargeController} 的方法级权限测试
  34 + *
  35 + * 通过真实的 Spring Security 方法拦截({@link EnableMethodSecurity})+ 真实的 {@code @ss} 权限判断,
  36 + * 校验发起钱包充值退款需要 pay:wallet:update-balance 权限,拒绝时不会调用 Service
  37 + *
  38 + * @author 芋道源码
  39 + */
  40 +@SpringJUnitConfig(PayWalletRechargeControllerSecurityTest.Config.class)
  41 +public class PayWalletRechargeControllerSecurityTest {
  42 +
  43 + private static final String PERMISSION = "pay:wallet:update-balance";
  44 + private static final Long USER_ID = 1L;
  45 + private static final Long RECHARGE_ID = 1024L;
  46 +
  47 + @Resource
  48 + private PayWalletRechargeController controller;
  49 + @Resource
  50 + private PayWalletRechargeService walletRechargeService;
  51 + @Resource
  52 + private PermissionCommonApi permissionApi;
  53 +
  54 + @BeforeEach
  55 + public void setUp() {
  56 + reset(walletRechargeService, permissionApi);
  57 + SecurityContextHolder.clearContext();
  58 + }
  59 +
  60 + @AfterEach
  61 + public void tearDown() {
  62 + SecurityContextHolder.clearContext();
  63 + }
  64 +
  65 + @Test
  66 + public void testControllerIsProxied() {
  67 + // 确认测试走的是方法安全代理,而不是裸对象
  68 + assertTrue(AopUtils.isAopProxy(controller));
  69 + }
  70 +
  71 + @Test
  72 + public void testRefundWalletRecharge_hasPermission() {
  73 + // mock 方法
  74 + loginAdmin();
  75 + when(permissionApi.hasAnyPermissions(eq(USER_ID), eq(PERMISSION))).thenReturn(true);
  76 +
  77 + // 调用
  78 + assertTrue(controller.refundWalletRecharge(RECHARGE_ID).getData());
  79 + // 断言
  80 + verify(permissionApi).hasAnyPermissions(eq(USER_ID), eq(PERMISSION));
  81 + verify(walletRechargeService).refundWalletRecharge(eq(RECHARGE_ID), isNull());
  82 + }
  83 +
  84 + @Test
  85 + public void testRefundWalletRecharge_noPermission() {
  86 + // mock 方法
  87 + loginAdmin();
  88 + when(permissionApi.hasAnyPermissions(anyLong(), any(String[].class))).thenReturn(false);
  89 +
  90 + // 调用,并断言
  91 + assertThrows(AccessDeniedException.class, () -> controller.refundWalletRecharge(RECHARGE_ID));
  92 + verify(permissionApi).hasAnyPermissions(eq(USER_ID), eq(PERMISSION));
  93 + verifyNoInteractions(walletRechargeService);
  94 + }
  95 +
  96 + @Test
  97 + public void testRefundWalletRecharge_anonymous() {
  98 + // 未登录:Web 过滤链会放入匿名用户
  99 + SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken("key", "anonymousUser",
  100 + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")));
  101 +
  102 + // 调用,并断言
  103 + assertThrows(AccessDeniedException.class, () -> controller.refundWalletRecharge(RECHARGE_ID));
  104 + verifyNoInteractions(permissionApi, walletRechargeService);
  105 + }
  106 +
  107 + @Test
  108 + public void testRefundWalletRecharge_noAuthentication() {
  109 + // 没有任何认证信息,同样拒绝:取决于 Spring Security 是否读取认证信息,抛出拒绝访问或认证缺失
  110 + Exception ex = assertThrows(Exception.class, () -> controller.refundWalletRecharge(RECHARGE_ID));
  111 + assertTrue(ex instanceof AccessDeniedException || ex instanceof AuthenticationCredentialsNotFoundException,
  112 + "非预期的异常:" + ex);
  113 + verifyNoInteractions(permissionApi, walletRechargeService);
  114 + }
  115 +
  116 + @Test
  117 + public void testUpdateWalletRechargerPaid_callbackNotRestricted() {
  118 + // 支付回调:仍由 @PermitAll + 内部校验,不受后台权限约束
  119 + PayOrderNotifyReqDTO reqDTO = PayOrderNotifyReqDTO.builder().merchantOrderId("1").payOrderId(2L).build();
  120 +
  121 + // 调用
  122 + assertTrue(controller.updateWalletRechargerPaid(reqDTO).getData());
  123 + // 断言
  124 + verify(walletRechargeService).updateWalletRechargerPaid(eq(1L), eq(2L));
  125 + verifyNoInteractions(permissionApi);
  126 + }
  127 +
  128 + private static void loginAdmin() {
  129 + LoginUser loginUser = new LoginUser();
  130 + loginUser.setId(USER_ID);
  131 + loginUser.setUserType(UserTypeEnum.ADMIN.getValue());
  132 + loginUser.setTenantId(1L);
  133 + SecurityContextHolder.getContext().setAuthentication(
  134 + new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList()));
  135 + }
  136 +
  137 + @Configuration
  138 + @EnableMethodSecurity(securedEnabled = true)
  139 + public static class Config {
  140 +
  141 + @Bean
  142 + public PayWalletRechargeController payWalletRechargeController() {
  143 + return new PayWalletRechargeController();
  144 + }
  145 +
  146 + @Bean
  147 + public PayWalletRechargeService payWalletRechargeService() {
  148 + return mock(PayWalletRechargeService.class);
  149 + }
  150 +
  151 + @Bean
  152 + public PermissionCommonApi permissionCommonApi() {
  153 + return mock(PermissionCommonApi.class);
  154 + }
  155 +
  156 + @Bean("ss")
  157 + public SecurityFrameworkService securityFrameworkService(PermissionCommonApi permissionApi) {
  158 + return new SecurityFrameworkServiceImpl(permissionApi);
  159 + }
  160 +
  161 + }
  162 +
  163 +}
... ...