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 | import cn.hutool.http.HttpUtil; |
| 7 | 7 | import cn.iocoder.yudao.framework.common.util.http.HttpUtils; |
| 8 | 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 | 10 | import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; |
| 10 | 11 | import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; |
| 11 | 12 | import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; |
| ... | ... | @@ -33,6 +34,8 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> { |
| 33 | 34 | |
| 34 | 35 | private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24); |
| 35 | 36 | |
| 37 | + private static final String CONTENT_DISPOSITION_ATTACHMENT = "attachment"; | |
| 38 | + | |
| 36 | 39 | private S3Client client; |
| 37 | 40 | private S3Presigner presigner; |
| 38 | 41 | |
| ... | ... | @@ -79,6 +82,8 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> { |
| 79 | 82 | .bucket(config.getBucket()) |
| 80 | 83 | .key(path) |
| 81 | 84 | .contentType(type) |
| 85 | + // SVG 等可携带脚本的文件:直链直接打开时按附件下载;页面中作为图片引用时不受影响 | |
| 86 | + .contentDisposition(FileTypeUtils.isScriptable(type) ? CONTENT_DISPOSITION_ATTACHMENT : null) | |
| 82 | 87 | .contentLength((long) content.length) |
| 83 | 88 | .build(); |
| 84 | 89 | // 上传文件 |
| ... | ... | @@ -133,9 +138,14 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> { |
| 133 | 138 | // 2.2 情况二:私有访问:生成 GET 预签名 URL |
| 134 | 139 | String finalPath = path; |
| 135 | 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 | 145 | URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder() |
| 137 | 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 | 149 | .url(); |
| 140 | 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 | 81 | public static void writeAttachment(HttpServletResponse response, String filename, byte[] content) throws IOException { |
| 82 | 82 | // 设置 header 和 contentType |
| 83 | 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 | 89 | response.setContentType(mineType); |
| 90 | + response.setHeader("X-Content-Type-Options", "nosniff"); | |
| 85 | 91 | // 设置内容显示、下载文件名:https://www.cnblogs.com/wq-9/articles/12165056.html |
| 86 | - if (isImage(mineType)) { | |
| 92 | + if (isImage(mineType) && !isScriptable(mineType)) { | |
| 87 | 93 | // 参见 https://github.com/YunaiV/ruoyi-vue-pro/issues/692 讨论 |
| 88 | 94 | response.setHeader("Content-Disposition", buildContentDisposition("inline", filename)); |
| 89 | 95 | } else { |
| 96 | + // SVG 等可携带脚本的文件:直接打开时按附件下载;保留原类型,页面中作为图片引用时仍可显示 | |
| 90 | 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 | 103 | // 针对 video 的特殊处理,解决视频地址在移动端播放的兼容性问题 |
| 93 | 104 | if (StrUtil.containsIgnoreCase(mineType, "video")) { |
| ... | ... | @@ -131,4 +142,16 @@ public class FileTypeUtils { |
| 131 | 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 | 2 | |
| 3 | 3 | import cn.hutool.core.io.resource.ResourceUtil; |
| 4 | 4 | import cn.hutool.core.util.IdUtil; |
| 5 | +import cn.hutool.core.util.ReflectUtil; | |
| 6 | +import cn.hutool.core.util.StrUtil; | |
| 5 | 7 | import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; |
| 6 | 8 | import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClient; |
| 7 | 9 | import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClientConfig; |
| 8 | 10 | import jakarta.validation.Validation; |
| 9 | 11 | import org.junit.jupiter.api.Disabled; |
| 10 | 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 | 18 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 13 | 19 | import static org.junit.jupiter.api.Assertions.assertFalse; |
| 20 | +import static org.junit.jupiter.api.Assertions.assertNull; | |
| 14 | 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 | 27 | @SuppressWarnings("resource") |
| 17 | 28 | public class S3FileClientTest { |
| ... | ... | @@ -67,6 +78,68 @@ public class S3FileClientTest { |
| 67 | 78 | assertTrue(result.contains("/yudao/avatar/%E4%B8%AD%E6%96%87%20100%25")); |
| 68 | 79 | assertTrue(result.contains("%E6%96%87%E4%BB%B6.jpg")); |
| 69 | 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 | 145 | @Test | ... | ... |
yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtilsTest.java
| 1 | 1 | package cn.iocoder.yudao.module.infra.framework.file.core.utils; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.io.resource.ResourceUtil; | |
| 3 | 4 | import org.junit.jupiter.api.Test; |
| 4 | 5 | import org.springframework.mock.web.MockHttpServletResponse; |
| 5 | 6 | |
| 6 | 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 | 12 | * {@link FileTypeUtils} 的单元测试 |
| ... | ... | @@ -26,7 +26,102 @@ public class FileTypeUtilsTest { |
| 26 | 26 | assertEquals("attachment;filename=\"__ 100%+__.txt\";" |
| 27 | 27 | + "filename*=UTF-8''%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.txt", |
| 28 | 28 | response.getHeader("Content-Disposition")); |
| 29 | + assertEquals("nosniff", response.getHeader("X-Content-Type-Options")); | |
| 30 | + assertNull(response.getHeader("Content-Security-Policy")); | |
| 29 | 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 | 252 | } |
| 253 | 253 | |
| 254 | 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 | 277 | public void testCreateFile_success_opus() throws Exception { |
| 256 | 278 | // 准备参数 |
| 257 | 279 | byte[] content = new byte[64]; | ... | ... |