Commit a970ea93c6efaa45cc712ac1628ebcdce53d11d6

Authored by YunaiV
1 parent 4ee2d0f6

fix(infra): 防止上传文件通过 SVG 响应执行脚本

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];
... ...