Commit a970ea93c6efaa45cc712ac1628ebcdce53d11d6
1 parent
4ee2d0f6
fix(infra): 防止上传文件通过 SVG 响应执行脚本
Showing
5 changed files
with
227 additions
and
4 deletions
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java
| @@ -6,6 +6,7 @@ import cn.hutool.core.util.StrUtil; | @@ -6,6 +6,7 @@ import cn.hutool.core.util.StrUtil; | ||
| 6 | import cn.hutool.http.HttpUtil; | 6 | import cn.hutool.http.HttpUtil; |
| 7 | import cn.iocoder.yudao.framework.common.util.http.HttpUtils; | 7 | import cn.iocoder.yudao.framework.common.util.http.HttpUtils; |
| 8 | import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; | 8 | import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; |
| 9 | +import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; | ||
| 9 | import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; | 10 | import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; |
| 10 | import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; | 11 | import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; |
| 11 | import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; | 12 | import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; |
| @@ -33,6 +34,8 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> { | @@ -33,6 +34,8 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> { | ||
| 33 | 34 | ||
| 34 | private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24); | 35 | private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24); |
| 35 | 36 | ||
| 37 | + private static final String CONTENT_DISPOSITION_ATTACHMENT = "attachment"; | ||
| 38 | + | ||
| 36 | private S3Client client; | 39 | private S3Client client; |
| 37 | private S3Presigner presigner; | 40 | private S3Presigner presigner; |
| 38 | 41 | ||
| @@ -79,6 +82,8 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> { | @@ -79,6 +82,8 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> { | ||
| 79 | .bucket(config.getBucket()) | 82 | .bucket(config.getBucket()) |
| 80 | .key(path) | 83 | .key(path) |
| 81 | .contentType(type) | 84 | .contentType(type) |
| 85 | + // SVG 等可携带脚本的文件:直链直接打开时按附件下载;页面中作为图片引用时不受影响 | ||
| 86 | + .contentDisposition(FileTypeUtils.isScriptable(type) ? CONTENT_DISPOSITION_ATTACHMENT : null) | ||
| 82 | .contentLength((long) content.length) | 87 | .contentLength((long) content.length) |
| 83 | .build(); | 88 | .build(); |
| 84 | // 上传文件 | 89 | // 上传文件 |
| @@ -133,9 +138,14 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> { | @@ -133,9 +138,14 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> { | ||
| 133 | // 2.2 情况二:私有访问:生成 GET 预签名 URL | 138 | // 2.2 情况二:私有访问:生成 GET 预签名 URL |
| 134 | String finalPath = path; | 139 | String finalPath = path; |
| 135 | Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT; | 140 | Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT; |
| 141 | + // 新签发的私有 URL:对象 key 扩展名为 SVG 等可携带脚本的类型时,要求按附件下载 | ||
| 142 | + // 注意:不覆盖公开访问、已签发的 URL,以及扩展名与 Content-Type 不一致的直传对象 | ||
| 143 | + String contentDisposition = FileTypeUtils.isScriptable(FileTypeUtils.getMineType(finalPath)) | ||
| 144 | + ? CONTENT_DISPOSITION_ATTACHMENT : null; | ||
| 136 | URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder() | 145 | URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder() |
| 137 | .signatureDuration(expiration) | 146 | .signatureDuration(expiration) |
| 138 | - .getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath)).build()) | 147 | + .getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath) |
| 148 | + .responseContentDisposition(contentDisposition)).build()) | ||
| 139 | .url(); | 149 | .url(); |
| 140 | return signedUrl.toString(); | 150 | return signedUrl.toString(); |
| 141 | } | 151 | } |
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java
| @@ -81,13 +81,24 @@ public class FileTypeUtils { | @@ -81,13 +81,24 @@ public class FileTypeUtils { | ||
| 81 | public static void writeAttachment(HttpServletResponse response, String filename, byte[] content) throws IOException { | 81 | public static void writeAttachment(HttpServletResponse response, String filename, byte[] content) throws IOException { |
| 82 | // 设置 header 和 contentType | 82 | // 设置 header 和 contentType |
| 83 | String mineType = getMineType(content, filename); | 83 | String mineType = getMineType(content, filename); |
| 84 | + // 文件名可能伪装,按内容识别为 SVG、HTML、XML 等可携带脚本的类型时,以内容为准 | ||
| 85 | + String contentMineType = getMineType(content); | ||
| 86 | + if (!isScriptable(mineType) && isScriptable(contentMineType)) { | ||
| 87 | + mineType = contentMineType; | ||
| 88 | + } | ||
| 84 | response.setContentType(mineType); | 89 | response.setContentType(mineType); |
| 90 | + response.setHeader("X-Content-Type-Options", "nosniff"); | ||
| 85 | // 设置内容显示、下载文件名:https://www.cnblogs.com/wq-9/articles/12165056.html | 91 | // 设置内容显示、下载文件名:https://www.cnblogs.com/wq-9/articles/12165056.html |
| 86 | - if (isImage(mineType)) { | 92 | + if (isImage(mineType) && !isScriptable(mineType)) { |
| 87 | // 参见 https://github.com/YunaiV/ruoyi-vue-pro/issues/692 讨论 | 93 | // 参见 https://github.com/YunaiV/ruoyi-vue-pro/issues/692 讨论 |
| 88 | response.setHeader("Content-Disposition", buildContentDisposition("inline", filename)); | 94 | response.setHeader("Content-Disposition", buildContentDisposition("inline", filename)); |
| 89 | } else { | 95 | } else { |
| 96 | + // SVG 等可携带脚本的文件:直接打开时按附件下载;保留原类型,页面中作为图片引用时仍可显示 | ||
| 90 | response.setHeader("Content-Disposition", buildContentDisposition("attachment", filename)); | 97 | response.setHeader("Content-Disposition", buildContentDisposition("attachment", filename)); |
| 98 | + // 被 object、embed 等嵌入时,按沙箱执行,禁止脚本、隔离来源 | ||
| 99 | + if (isScriptable(mineType)) { | ||
| 100 | + response.setHeader("Content-Security-Policy", "sandbox"); | ||
| 101 | + } | ||
| 91 | } | 102 | } |
| 92 | // 针对 video 的特殊处理,解决视频地址在移动端播放的兼容性问题 | 103 | // 针对 video 的特殊处理,解决视频地址在移动端播放的兼容性问题 |
| 93 | if (StrUtil.containsIgnoreCase(mineType, "video")) { | 104 | if (StrUtil.containsIgnoreCase(mineType, "video")) { |
| @@ -131,4 +142,16 @@ public class FileTypeUtils { | @@ -131,4 +142,16 @@ public class FileTypeUtils { | ||
| 131 | return StrUtil.startWith(mineType, "image/"); | 142 | return StrUtil.startWith(mineType, "image/"); |
| 132 | } | 143 | } |
| 133 | 144 | ||
| 145 | + /** | ||
| 146 | + * 判断是否是可携带脚本的类型,例如说 SVG、HTML、XHTML、XML,同源直接打开时会执行其中的脚本 | ||
| 147 | + * | ||
| 148 | + * 注意:Office 的 openxmlformats 类型以 document、sheet 等结尾,不属于此类 | ||
| 149 | + * | ||
| 150 | + * @param mineType 类型 | ||
| 151 | + * @return 是否可携带脚本 | ||
| 152 | + */ | ||
| 153 | + public static boolean isScriptable(String mineType) { | ||
| 154 | + return StrUtil.containsAnyIgnoreCase(mineType, "svg", "html") || StrUtil.endWithIgnoreCase(mineType, "xml"); | ||
| 155 | + } | ||
| 156 | + | ||
| 134 | } | 157 | } |
yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java
| @@ -2,16 +2,27 @@ package cn.iocoder.yudao.module.infra.framework.file.core.s3; | @@ -2,16 +2,27 @@ package cn.iocoder.yudao.module.infra.framework.file.core.s3; | ||
| 2 | 2 | ||
| 3 | import cn.hutool.core.io.resource.ResourceUtil; | 3 | import cn.hutool.core.io.resource.ResourceUtil; |
| 4 | import cn.hutool.core.util.IdUtil; | 4 | import cn.hutool.core.util.IdUtil; |
| 5 | +import cn.hutool.core.util.ReflectUtil; | ||
| 6 | +import cn.hutool.core.util.StrUtil; | ||
| 5 | import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; | 7 | import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; |
| 6 | import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClient; | 8 | import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClient; |
| 7 | import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClientConfig; | 9 | import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClientConfig; |
| 8 | import jakarta.validation.Validation; | 10 | import jakarta.validation.Validation; |
| 9 | import org.junit.jupiter.api.Disabled; | 11 | import org.junit.jupiter.api.Disabled; |
| 10 | import org.junit.jupiter.api.Test; | 12 | import org.junit.jupiter.api.Test; |
| 13 | +import org.mockito.ArgumentCaptor; | ||
| 14 | +import software.amazon.awssdk.core.sync.RequestBody; | ||
| 15 | +import software.amazon.awssdk.services.s3.S3Client; | ||
| 16 | +import software.amazon.awssdk.services.s3.model.PutObjectRequest; | ||
| 11 | 17 | ||
| 12 | import static org.junit.jupiter.api.Assertions.assertEquals; | 18 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 13 | import static org.junit.jupiter.api.Assertions.assertFalse; | 19 | import static org.junit.jupiter.api.Assertions.assertFalse; |
| 20 | +import static org.junit.jupiter.api.Assertions.assertNull; | ||
| 14 | import static org.junit.jupiter.api.Assertions.assertTrue; | 21 | import static org.junit.jupiter.api.Assertions.assertTrue; |
| 22 | +import static org.mockito.ArgumentMatchers.any; | ||
| 23 | +import static org.mockito.Mockito.mock; | ||
| 24 | +import static org.mockito.Mockito.times; | ||
| 25 | +import static org.mockito.Mockito.verify; | ||
| 15 | 26 | ||
| 16 | @SuppressWarnings("resource") | 27 | @SuppressWarnings("resource") |
| 17 | public class S3FileClientTest { | 28 | public class S3FileClientTest { |
| @@ -67,6 +78,68 @@ public class S3FileClientTest { | @@ -67,6 +78,68 @@ public class S3FileClientTest { | ||
| 67 | assertTrue(result.contains("/yudao/avatar/%E4%B8%AD%E6%96%87%20100%25")); | 78 | assertTrue(result.contains("/yudao/avatar/%E4%B8%AD%E6%96%87%20100%25")); |
| 68 | assertTrue(result.contains("%E6%96%87%E4%BB%B6.jpg")); | 79 | assertTrue(result.contains("%E6%96%87%E4%BB%B6.jpg")); |
| 69 | assertFalse(result.contains("%25E4%25B8%25AD")); | 80 | assertFalse(result.contains("%25E4%25B8%25AD")); |
| 81 | + assertFalse(result.contains("response-content-disposition")); | ||
| 82 | + } | ||
| 83 | + | ||
| 84 | + @Test | ||
| 85 | + public void testPresignUrl_privateAccess_scriptableAttachment() { | ||
| 86 | + // 准备参数 | ||
| 87 | + S3FileClientConfig config = new S3FileClientConfig(); | ||
| 88 | + config.setAccessKey("admin"); | ||
| 89 | + config.setAccessSecret("password"); | ||
| 90 | + config.setBucket("yudao"); | ||
| 91 | + config.setDomain("http://127.0.0.1:9000/yudao"); | ||
| 92 | + config.setEndpoint("http://127.0.0.1:9000"); | ||
| 93 | + config.setEnablePathStyleAccess(true); | ||
| 94 | + config.setEnablePublicAccess(false); | ||
| 95 | + S3FileClient client = new S3FileClient(0L, config); | ||
| 96 | + client.init(); | ||
| 97 | + | ||
| 98 | + // 调用:新上传的 SVG、已保存的历史签名 URL 重新签名 | ||
| 99 | + String result = client.presignGetUrl("avatar/图标.svg", 300); | ||
| 100 | + String historyResult = client.presignGetUrl( | ||
| 101 | + "http://127.0.0.1:9000/yudao/avatar/%E5%9B%BE%E6%A0%87.svg?X-Amz-Signature=old", 300); | ||
| 102 | + String putResult = client.presignPutUrl("avatar/图标.svg"); | ||
| 103 | + | ||
| 104 | + // 断言:GET 签名要求按附件下载;PUT 签名不额外约束请求头,兼容前端直传 | ||
| 105 | + assertTrue(result.contains("/yudao/avatar/%E5%9B%BE%E6%A0%87.svg")); | ||
| 106 | + assertTrue(result.contains("response-content-disposition=attachment")); | ||
| 107 | + assertTrue(historyResult.contains("/yudao/avatar/%E5%9B%BE%E6%A0%87.svg")); | ||
| 108 | + assertTrue(historyResult.contains("response-content-disposition=attachment")); | ||
| 109 | + assertFalse(historyResult.contains("X-Amz-Signature=old")); | ||
| 110 | + assertFalse(StrUtil.containsIgnoreCase(putResult, "content-disposition")); | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + @Test | ||
| 114 | + public void testUpload_scriptableContentDisposition() { | ||
| 115 | + // 准备参数 | ||
| 116 | + S3FileClientConfig config = new S3FileClientConfig(); | ||
| 117 | + config.setAccessKey("admin"); | ||
| 118 | + config.setAccessSecret("password"); | ||
| 119 | + config.setBucket("yudao"); | ||
| 120 | + config.setDomain("http://127.0.0.1:9000/yudao"); | ||
| 121 | + config.setEndpoint("http://127.0.0.1:9000"); | ||
| 122 | + config.setEnablePathStyleAccess(true); | ||
| 123 | + config.setEnablePublicAccess(true); | ||
| 124 | + S3FileClient client = new S3FileClient(0L, config); | ||
| 125 | + client.init(); | ||
| 126 | + // mock S3 客户端 | ||
| 127 | + S3Client s3Client = mock(S3Client.class); | ||
| 128 | + ReflectUtil.setFieldValue(client, "client", s3Client); | ||
| 129 | + | ||
| 130 | + // 调用 | ||
| 131 | + client.upload(new byte[]{1}, "avatar/图标.svg", "image/svg+xml"); | ||
| 132 | + client.upload(new byte[]{1}, "avatar/页面.html", "text/html"); | ||
| 133 | + client.upload(new byte[]{1}, "avatar/头像.jpg", "image/jpeg"); | ||
| 134 | + | ||
| 135 | + // 断言:可携带脚本的对象按附件下载,普通图片不设置 | ||
| 136 | + ArgumentCaptor<PutObjectRequest> requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); | ||
| 137 | + verify(s3Client, times(3)).putObject(requestCaptor.capture(), any(RequestBody.class)); | ||
| 138 | + assertEquals("image/svg+xml", requestCaptor.getAllValues().get(0).contentType()); | ||
| 139 | + assertEquals("attachment", requestCaptor.getAllValues().get(0).contentDisposition()); | ||
| 140 | + assertEquals("attachment", requestCaptor.getAllValues().get(1).contentDisposition()); | ||
| 141 | + assertEquals("image/jpeg", requestCaptor.getAllValues().get(2).contentType()); | ||
| 142 | + assertNull(requestCaptor.getAllValues().get(2).contentDisposition()); | ||
| 70 | } | 143 | } |
| 71 | 144 | ||
| 72 | @Test | 145 | @Test |
yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtilsTest.java
| 1 | package cn.iocoder.yudao.module.infra.framework.file.core.utils; | 1 | package cn.iocoder.yudao.module.infra.framework.file.core.utils; |
| 2 | 2 | ||
| 3 | +import cn.hutool.core.io.resource.ResourceUtil; | ||
| 3 | import org.junit.jupiter.api.Test; | 4 | import org.junit.jupiter.api.Test; |
| 4 | import org.springframework.mock.web.MockHttpServletResponse; | 5 | import org.springframework.mock.web.MockHttpServletResponse; |
| 5 | 6 | ||
| 6 | import java.nio.charset.StandardCharsets; | 7 | import java.nio.charset.StandardCharsets; |
| 7 | 8 | ||
| 8 | -import static org.junit.jupiter.api.Assertions.assertArrayEquals; | ||
| 9 | -import static org.junit.jupiter.api.Assertions.assertEquals; | 9 | +import static org.junit.jupiter.api.Assertions.*; |
| 10 | 10 | ||
| 11 | /** | 11 | /** |
| 12 | * {@link FileTypeUtils} 的单元测试 | 12 | * {@link FileTypeUtils} 的单元测试 |
| @@ -26,7 +26,102 @@ public class FileTypeUtilsTest { | @@ -26,7 +26,102 @@ public class FileTypeUtilsTest { | ||
| 26 | assertEquals("attachment;filename=\"__ 100%+__.txt\";" | 26 | assertEquals("attachment;filename=\"__ 100%+__.txt\";" |
| 27 | + "filename*=UTF-8''%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.txt", | 27 | + "filename*=UTF-8''%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.txt", |
| 28 | response.getHeader("Content-Disposition")); | 28 | response.getHeader("Content-Disposition")); |
| 29 | + assertEquals("nosniff", response.getHeader("X-Content-Type-Options")); | ||
| 30 | + assertNull(response.getHeader("Content-Security-Policy")); | ||
| 29 | assertArrayEquals(content, response.getContentAsByteArray()); | 31 | assertArrayEquals(content, response.getContentAsByteArray()); |
| 30 | } | 32 | } |
| 31 | 33 | ||
| 34 | + @Test | ||
| 35 | + public void testWriteAttachment_imageInline() throws Exception { | ||
| 36 | + // 准备参数 | ||
| 37 | + MockHttpServletResponse response = new MockHttpServletResponse(); | ||
| 38 | + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); | ||
| 39 | + | ||
| 40 | + // 调用 | ||
| 41 | + FileTypeUtils.writeAttachment(response, "头像.jpg", content); | ||
| 42 | + | ||
| 43 | + // 断言:普通位图保持 inline,可直接预览 | ||
| 44 | + assertEquals("image/jpeg", response.getContentType()); | ||
| 45 | + assertEquals("inline;filename=\"__.jpg\";filename*=UTF-8''%E5%A4%B4%E5%83%8F.jpg", | ||
| 46 | + response.getHeader("Content-Disposition")); | ||
| 47 | + assertEquals("nosniff", response.getHeader("X-Content-Type-Options")); | ||
| 48 | + assertNull(response.getHeader("Content-Security-Policy")); | ||
| 49 | + assertArrayEquals(content, response.getContentAsByteArray()); | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + @Test | ||
| 53 | + public void testWriteAttachment_scriptableAttachment() throws Exception { | ||
| 54 | + // 历史已上传的 SVG、HTML、XML 文件,没有文件记录时按路径中的文件名下载 | ||
| 55 | + for (String[] document : new String[][]{ | ||
| 56 | + {"图标.svg", "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>", | ||
| 57 | + "image/svg+xml", "attachment;filename=\"__.svg\";filename*=UTF-8''%E5%9B%BE%E6%A0%87.svg"}, | ||
| 58 | + {"页面.html", "<html><script>alert(1)</script></html>", | ||
| 59 | + "text/html", "attachment;filename=\"__.html\";filename*=UTF-8''%E9%A1%B5%E9%9D%A2.html"}, | ||
| 60 | + {"数据.xml", "<?xml version=\"1.0\"?><root>test</root>", | ||
| 61 | + "application/xml", "attachment;filename=\"__.xml\";filename*=UTF-8''%E6%95%B0%E6%8D%AE.xml"}}) { | ||
| 62 | + // 准备参数 | ||
| 63 | + MockHttpServletResponse response = new MockHttpServletResponse(); | ||
| 64 | + byte[] content = document[1].getBytes(StandardCharsets.UTF_8); | ||
| 65 | + | ||
| 66 | + // 调用 | ||
| 67 | + FileTypeUtils.writeAttachment(response, document[0], content); | ||
| 68 | + | ||
| 69 | + // 断言:保留原类型,直接打开时按附件下载,嵌入时按沙箱执行 | ||
| 70 | + assertEquals(document[2], response.getContentType()); | ||
| 71 | + assertEquals(document[3], response.getHeader("Content-Disposition")); | ||
| 72 | + assertEquals("nosniff", response.getHeader("X-Content-Type-Options")); | ||
| 73 | + assertEquals("sandbox", response.getHeader("Content-Security-Policy")); | ||
| 74 | + assertArrayEquals(content, response.getContentAsByteArray()); | ||
| 75 | + } | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + @Test | ||
| 79 | + public void testWriteAttachment_fakeImageNameDetectContent() throws Exception { | ||
| 80 | + // 准备参数:SVG 内容伪装成 png 文件名 | ||
| 81 | + MockHttpServletResponse response = new MockHttpServletResponse(); | ||
| 82 | + byte[] content = "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>" | ||
| 83 | + .getBytes(StandardCharsets.UTF_8); | ||
| 84 | + | ||
| 85 | + // 调用 | ||
| 86 | + FileTypeUtils.writeAttachment(response, "图标.png", content); | ||
| 87 | + | ||
| 88 | + // 断言:以内容识别的 SVG 类型为准,按附件下载 | ||
| 89 | + assertEquals("image/svg+xml", response.getContentType()); | ||
| 90 | + assertEquals("attachment;filename=\"__.png\";filename*=UTF-8''%E5%9B%BE%E6%A0%87.png", | ||
| 91 | + response.getHeader("Content-Disposition")); | ||
| 92 | + assertEquals("nosniff", response.getHeader("X-Content-Type-Options")); | ||
| 93 | + assertEquals("sandbox", response.getHeader("Content-Security-Policy")); | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + @Test | ||
| 97 | + public void testWriteAttachment_imageNameHintDetectContent() throws Exception { | ||
| 98 | + // 准备参数:XML 内容,文件名提示会把类型识别为图片(image/vnd.adobe.premiere) | ||
| 99 | + MockHttpServletResponse response = new MockHttpServletResponse(); | ||
| 100 | + byte[] content = "<?xml version=\"1.0\"?><root>test</root>".getBytes(StandardCharsets.UTF_8); | ||
| 101 | + | ||
| 102 | + // 调用 | ||
| 103 | + FileTypeUtils.writeAttachment(response, "test.ppj", content); | ||
| 104 | + | ||
| 105 | + // 断言:仅按内容识别为 XML 时以内容为准,不按图片 inline | ||
| 106 | + assertEquals("application/xml", response.getContentType()); | ||
| 107 | + assertEquals("attachment;filename=\"test.ppj\";filename*=UTF-8''test.ppj", | ||
| 108 | + response.getHeader("Content-Disposition")); | ||
| 109 | + assertEquals("nosniff", response.getHeader("X-Content-Type-Options")); | ||
| 110 | + assertEquals("sandbox", response.getHeader("Content-Security-Policy")); | ||
| 111 | + assertArrayEquals(content, response.getContentAsByteArray()); | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + @Test | ||
| 115 | + public void testIsScriptable() { | ||
| 116 | + assertTrue(FileTypeUtils.isScriptable("image/svg+xml")); | ||
| 117 | + assertTrue(FileTypeUtils.isScriptable("text/html")); | ||
| 118 | + assertTrue(FileTypeUtils.isScriptable("application/xhtml+xml")); | ||
| 119 | + assertTrue(FileTypeUtils.isScriptable("application/xml")); | ||
| 120 | + assertTrue(FileTypeUtils.isScriptable("text/xml")); | ||
| 121 | + assertFalse(FileTypeUtils.isScriptable("image/png")); | ||
| 122 | + assertFalse(FileTypeUtils.isScriptable("application/pdf")); | ||
| 123 | + assertFalse(FileTypeUtils.isScriptable("application/vnd.openxmlformats-officedocument.wordprocessingml.document")); | ||
| 124 | + assertFalse(FileTypeUtils.isScriptable(null)); | ||
| 125 | + } | ||
| 126 | + | ||
| 32 | } | 127 | } |
yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java
| @@ -252,6 +252,28 @@ public class FileServiceImplTest extends BaseDbUnitTest { | @@ -252,6 +252,28 @@ public class FileServiceImplTest extends BaseDbUnitTest { | ||
| 252 | } | 252 | } |
| 253 | 253 | ||
| 254 | @Test | 254 | @Test |
| 255 | + public void testCreateFile_success_fakeExtensionDetectContent() throws Exception { | ||
| 256 | + for (String name : new String[]{"图标.xml", "图标.txt"}) { | ||
| 257 | + // 准备参数:SVG 内容伪装成其他扩展名 | ||
| 258 | + byte[] content = "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>" | ||
| 259 | + .getBytes(StandardCharsets.UTF_8); | ||
| 260 | + // mock 方法 | ||
| 261 | + FileClient client = mock(FileClient.class); | ||
| 262 | + when(fileConfigService.getMasterFileClient()).thenReturn(client); | ||
| 263 | + String url = randomString(); | ||
| 264 | + when(client.upload(same(content), anyString(), eq("image/svg+xml"))).thenReturn(url); | ||
| 265 | + | ||
| 266 | + // 调用 | ||
| 267 | + String result = fileService.createFile(content, name, null, "text/plain"); | ||
| 268 | + // 断言:以内容识别的类型上传,文件存储器据此按附件下载 | ||
| 269 | + assertEquals(url, result); | ||
| 270 | + FileDO file = fileMapper.selectOne(FileDO::getUrl, url); | ||
| 271 | + assertEquals(name, file.getName()); | ||
| 272 | + assertEquals("image/svg+xml", file.getType()); | ||
| 273 | + } | ||
| 274 | + } | ||
| 275 | + | ||
| 276 | + @Test | ||
| 255 | public void testCreateFile_success_opus() throws Exception { | 277 | public void testCreateFile_success_opus() throws Exception { |
| 256 | // 准备参数 | 278 | // 准备参数 |
| 257 | byte[] content = new byte[64]; | 279 | byte[] content = new byte[64]; |