From a970ea93c6efaa45cc712ac1628ebcdce53d11d6 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 23 Oct 2026 00:39:28 +0800 Subject: [PATCH] fix(infra): 防止上传文件通过 SVG 响应执行脚本 --- yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java | 12 +++++++++++- yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java | 25 ++++++++++++++++++++++++- yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtilsTest.java | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java | 22 ++++++++++++++++++++++ 5 files changed, 227 insertions(+), 4 deletions(-) diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java index 4339904..34928d6 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java +++ b/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; import cn.hutool.http.HttpUtil; import cn.iocoder.yudao.framework.common.util.http.HttpUtils; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; @@ -33,6 +34,8 @@ public class S3FileClient extends AbstractFileClient { private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24); + private static final String CONTENT_DISPOSITION_ATTACHMENT = "attachment"; + private S3Client client; private S3Presigner presigner; @@ -79,6 +82,8 @@ public class S3FileClient extends AbstractFileClient { .bucket(config.getBucket()) .key(path) .contentType(type) + // SVG 等可携带脚本的文件:直链直接打开时按附件下载;页面中作为图片引用时不受影响 + .contentDisposition(FileTypeUtils.isScriptable(type) ? CONTENT_DISPOSITION_ATTACHMENT : null) .contentLength((long) content.length) .build(); // 上传文件 @@ -133,9 +138,14 @@ public class S3FileClient extends AbstractFileClient { // 2.2 情况二:私有访问:生成 GET 预签名 URL String finalPath = path; Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT; + // 新签发的私有 URL:对象 key 扩展名为 SVG 等可携带脚本的类型时,要求按附件下载 + // 注意:不覆盖公开访问、已签发的 URL,以及扩展名与 Content-Type 不一致的直传对象 + String contentDisposition = FileTypeUtils.isScriptable(FileTypeUtils.getMineType(finalPath)) + ? CONTENT_DISPOSITION_ATTACHMENT : null; URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder() .signatureDuration(expiration) - .getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath)).build()) + .getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath) + .responseContentDisposition(contentDisposition)).build()) .url(); return signedUrl.toString(); } diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java index 3b0b533..56feaa4 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java @@ -81,13 +81,24 @@ public class FileTypeUtils { public static void writeAttachment(HttpServletResponse response, String filename, byte[] content) throws IOException { // 设置 header 和 contentType String mineType = getMineType(content, filename); + // 文件名可能伪装,按内容识别为 SVG、HTML、XML 等可携带脚本的类型时,以内容为准 + String contentMineType = getMineType(content); + if (!isScriptable(mineType) && isScriptable(contentMineType)) { + mineType = contentMineType; + } response.setContentType(mineType); + response.setHeader("X-Content-Type-Options", "nosniff"); // 设置内容显示、下载文件名:https://www.cnblogs.com/wq-9/articles/12165056.html - if (isImage(mineType)) { + if (isImage(mineType) && !isScriptable(mineType)) { // 参见 https://github.com/YunaiV/ruoyi-vue-pro/issues/692 讨论 response.setHeader("Content-Disposition", buildContentDisposition("inline", filename)); } else { + // SVG 等可携带脚本的文件:直接打开时按附件下载;保留原类型,页面中作为图片引用时仍可显示 response.setHeader("Content-Disposition", buildContentDisposition("attachment", filename)); + // 被 object、embed 等嵌入时,按沙箱执行,禁止脚本、隔离来源 + if (isScriptable(mineType)) { + response.setHeader("Content-Security-Policy", "sandbox"); + } } // 针对 video 的特殊处理,解决视频地址在移动端播放的兼容性问题 if (StrUtil.containsIgnoreCase(mineType, "video")) { @@ -131,4 +142,16 @@ public class FileTypeUtils { return StrUtil.startWith(mineType, "image/"); } + /** + * 判断是否是可携带脚本的类型,例如说 SVG、HTML、XHTML、XML,同源直接打开时会执行其中的脚本 + * + * 注意:Office 的 openxmlformats 类型以 document、sheet 等结尾,不属于此类 + * + * @param mineType 类型 + * @return 是否可携带脚本 + */ + public static boolean isScriptable(String mineType) { + return StrUtil.containsAnyIgnoreCase(mineType, "svg", "html") || StrUtil.endWithIgnoreCase(mineType, "xml"); + } + } diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java index 73355ad..8e149ff 100644 --- a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java +++ b/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; import cn.hutool.core.io.resource.ResourceUtil; import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClient; import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClientConfig; import jakarta.validation.Validation; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; @SuppressWarnings("resource") public class S3FileClientTest { @@ -67,6 +78,68 @@ public class S3FileClientTest { assertTrue(result.contains("/yudao/avatar/%E4%B8%AD%E6%96%87%20100%25")); assertTrue(result.contains("%E6%96%87%E4%BB%B6.jpg")); assertFalse(result.contains("%25E4%25B8%25AD")); + assertFalse(result.contains("response-content-disposition")); + } + + @Test + public void testPresignUrl_privateAccess_scriptableAttachment() { + // 准备参数 + S3FileClientConfig config = new S3FileClientConfig(); + config.setAccessKey("admin"); + config.setAccessSecret("password"); + config.setBucket("yudao"); + config.setDomain("http://127.0.0.1:9000/yudao"); + config.setEndpoint("http://127.0.0.1:9000"); + config.setEnablePathStyleAccess(true); + config.setEnablePublicAccess(false); + S3FileClient client = new S3FileClient(0L, config); + client.init(); + + // 调用:新上传的 SVG、已保存的历史签名 URL 重新签名 + String result = client.presignGetUrl("avatar/图标.svg", 300); + String historyResult = client.presignGetUrl( + "http://127.0.0.1:9000/yudao/avatar/%E5%9B%BE%E6%A0%87.svg?X-Amz-Signature=old", 300); + String putResult = client.presignPutUrl("avatar/图标.svg"); + + // 断言:GET 签名要求按附件下载;PUT 签名不额外约束请求头,兼容前端直传 + assertTrue(result.contains("/yudao/avatar/%E5%9B%BE%E6%A0%87.svg")); + assertTrue(result.contains("response-content-disposition=attachment")); + assertTrue(historyResult.contains("/yudao/avatar/%E5%9B%BE%E6%A0%87.svg")); + assertTrue(historyResult.contains("response-content-disposition=attachment")); + assertFalse(historyResult.contains("X-Amz-Signature=old")); + assertFalse(StrUtil.containsIgnoreCase(putResult, "content-disposition")); + } + + @Test + public void testUpload_scriptableContentDisposition() { + // 准备参数 + S3FileClientConfig config = new S3FileClientConfig(); + config.setAccessKey("admin"); + config.setAccessSecret("password"); + config.setBucket("yudao"); + config.setDomain("http://127.0.0.1:9000/yudao"); + config.setEndpoint("http://127.0.0.1:9000"); + config.setEnablePathStyleAccess(true); + config.setEnablePublicAccess(true); + S3FileClient client = new S3FileClient(0L, config); + client.init(); + // mock S3 客户端 + S3Client s3Client = mock(S3Client.class); + ReflectUtil.setFieldValue(client, "client", s3Client); + + // 调用 + client.upload(new byte[]{1}, "avatar/图标.svg", "image/svg+xml"); + client.upload(new byte[]{1}, "avatar/页面.html", "text/html"); + client.upload(new byte[]{1}, "avatar/头像.jpg", "image/jpeg"); + + // 断言:可携带脚本的对象按附件下载,普通图片不设置 + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client, times(3)).putObject(requestCaptor.capture(), any(RequestBody.class)); + assertEquals("image/svg+xml", requestCaptor.getAllValues().get(0).contentType()); + assertEquals("attachment", requestCaptor.getAllValues().get(0).contentDisposition()); + assertEquals("attachment", requestCaptor.getAllValues().get(1).contentDisposition()); + assertEquals("image/jpeg", requestCaptor.getAllValues().get(2).contentType()); + assertNull(requestCaptor.getAllValues().get(2).contentDisposition()); } @Test diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtilsTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtilsTest.java index d13884b..84885dc 100644 --- a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtilsTest.java +++ b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtilsTest.java @@ -1,12 +1,12 @@ package cn.iocoder.yudao.module.infra.framework.file.core.utils; +import cn.hutool.core.io.resource.ResourceUtil; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletResponse; import java.nio.charset.StandardCharsets; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; /** * {@link FileTypeUtils} 的单元测试 @@ -26,7 +26,102 @@ public class FileTypeUtilsTest { assertEquals("attachment;filename=\"__ 100%+__.txt\";" + "filename*=UTF-8''%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.txt", response.getHeader("Content-Disposition")); + assertEquals("nosniff", response.getHeader("X-Content-Type-Options")); + assertNull(response.getHeader("Content-Security-Policy")); assertArrayEquals(content, response.getContentAsByteArray()); } + @Test + public void testWriteAttachment_imageInline() throws Exception { + // 准备参数 + MockHttpServletResponse response = new MockHttpServletResponse(); + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); + + // 调用 + FileTypeUtils.writeAttachment(response, "头像.jpg", content); + + // 断言:普通位图保持 inline,可直接预览 + assertEquals("image/jpeg", response.getContentType()); + assertEquals("inline;filename=\"__.jpg\";filename*=UTF-8''%E5%A4%B4%E5%83%8F.jpg", + response.getHeader("Content-Disposition")); + assertEquals("nosniff", response.getHeader("X-Content-Type-Options")); + assertNull(response.getHeader("Content-Security-Policy")); + assertArrayEquals(content, response.getContentAsByteArray()); + } + + @Test + public void testWriteAttachment_scriptableAttachment() throws Exception { + // 历史已上传的 SVG、HTML、XML 文件,没有文件记录时按路径中的文件名下载 + for (String[] document : new String[][]{ + {"图标.svg", "", + "image/svg+xml", "attachment;filename=\"__.svg\";filename*=UTF-8''%E5%9B%BE%E6%A0%87.svg"}, + {"页面.html", "", + "text/html", "attachment;filename=\"__.html\";filename*=UTF-8''%E9%A1%B5%E9%9D%A2.html"}, + {"数据.xml", "test", + "application/xml", "attachment;filename=\"__.xml\";filename*=UTF-8''%E6%95%B0%E6%8D%AE.xml"}}) { + // 准备参数 + MockHttpServletResponse response = new MockHttpServletResponse(); + byte[] content = document[1].getBytes(StandardCharsets.UTF_8); + + // 调用 + FileTypeUtils.writeAttachment(response, document[0], content); + + // 断言:保留原类型,直接打开时按附件下载,嵌入时按沙箱执行 + assertEquals(document[2], response.getContentType()); + assertEquals(document[3], response.getHeader("Content-Disposition")); + assertEquals("nosniff", response.getHeader("X-Content-Type-Options")); + assertEquals("sandbox", response.getHeader("Content-Security-Policy")); + assertArrayEquals(content, response.getContentAsByteArray()); + } + } + + @Test + public void testWriteAttachment_fakeImageNameDetectContent() throws Exception { + // 准备参数:SVG 内容伪装成 png 文件名 + MockHttpServletResponse response = new MockHttpServletResponse(); + byte[] content = "" + .getBytes(StandardCharsets.UTF_8); + + // 调用 + FileTypeUtils.writeAttachment(response, "图标.png", content); + + // 断言:以内容识别的 SVG 类型为准,按附件下载 + assertEquals("image/svg+xml", response.getContentType()); + assertEquals("attachment;filename=\"__.png\";filename*=UTF-8''%E5%9B%BE%E6%A0%87.png", + response.getHeader("Content-Disposition")); + assertEquals("nosniff", response.getHeader("X-Content-Type-Options")); + assertEquals("sandbox", response.getHeader("Content-Security-Policy")); + } + + @Test + public void testWriteAttachment_imageNameHintDetectContent() throws Exception { + // 准备参数:XML 内容,文件名提示会把类型识别为图片(image/vnd.adobe.premiere) + MockHttpServletResponse response = new MockHttpServletResponse(); + byte[] content = "test".getBytes(StandardCharsets.UTF_8); + + // 调用 + FileTypeUtils.writeAttachment(response, "test.ppj", content); + + // 断言:仅按内容识别为 XML 时以内容为准,不按图片 inline + assertEquals("application/xml", response.getContentType()); + assertEquals("attachment;filename=\"test.ppj\";filename*=UTF-8''test.ppj", + response.getHeader("Content-Disposition")); + assertEquals("nosniff", response.getHeader("X-Content-Type-Options")); + assertEquals("sandbox", response.getHeader("Content-Security-Policy")); + assertArrayEquals(content, response.getContentAsByteArray()); + } + + @Test + public void testIsScriptable() { + assertTrue(FileTypeUtils.isScriptable("image/svg+xml")); + assertTrue(FileTypeUtils.isScriptable("text/html")); + assertTrue(FileTypeUtils.isScriptable("application/xhtml+xml")); + assertTrue(FileTypeUtils.isScriptable("application/xml")); + assertTrue(FileTypeUtils.isScriptable("text/xml")); + assertFalse(FileTypeUtils.isScriptable("image/png")); + assertFalse(FileTypeUtils.isScriptable("application/pdf")); + assertFalse(FileTypeUtils.isScriptable("application/vnd.openxmlformats-officedocument.wordprocessingml.document")); + assertFalse(FileTypeUtils.isScriptable(null)); + } + } 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 8d1eacc..081dfcd 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 @@ -252,6 +252,28 @@ public class FileServiceImplTest extends BaseDbUnitTest { } @Test + public void testCreateFile_success_fakeExtensionDetectContent() throws Exception { + for (String name : new String[]{"图标.xml", "图标.txt"}) { + // 准备参数:SVG 内容伪装成其他扩展名 + byte[] content = "" + .getBytes(StandardCharsets.UTF_8); + // mock 方法 + FileClient client = mock(FileClient.class); + when(fileConfigService.getMasterFileClient()).thenReturn(client); + String url = randomString(); + when(client.upload(same(content), anyString(), eq("image/svg+xml"))).thenReturn(url); + + // 调用 + String result = fileService.createFile(content, name, null, "text/plain"); + // 断言:以内容识别的类型上传,文件存储器据此按附件下载 + assertEquals(url, result); + FileDO file = fileMapper.selectOne(FileDO::getUrl, url); + assertEquals(name, file.getName()); + assertEquals("image/svg+xml", file.getType()); + } + } + + @Test public void testCreateFile_success_opus() throws Exception { // 准备参数 byte[] content = new byte[64]; -- libgit2 0.21.4