Browse Source

fix(security): 修复上传路径穿越与SSRF

增加OSS后缀白名单与URL安全校验,修复达人上传路径穿越及声纹/FastGpt SSRF。

Co-authored-by: Cursor <cursoragent@cursor.com>
吴树波 2 days ago
parent
commit
c9551cfbf9

+ 3 - 4
fs-admin/src/main/java/com/fs/web/controller/common/CommonController.java

@@ -6,6 +6,7 @@ import javax.servlet.http.HttpServletResponse;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.file.OssException;
+import com.fs.common.utils.security.OssUploadSecurityUtils;
 import com.fs.course.dto.BatchSendCourseAllDTO;;
 import com.fs.course.service.ITencentCloudCosService;
 import com.fs.framework.config.ServerConfig;
@@ -174,8 +175,7 @@ public class CommonController
             throw new OssException("上传文件不能为空");
         }
         // 上传文件
-        String fileName = file.getOriginalFilename();
-        String suffix = fileName.substring(fileName.lastIndexOf("."));
+        String suffix = OssUploadSecurityUtils.validateAndGetDotSuffix(file);
         CloudStorageService storage = OSSFactory.build();
         String url = storage.uploadSuffix(file.getBytes(), suffix);
         return R.ok().put("url",url);
@@ -191,8 +191,7 @@ public class CommonController
                 throw new OssException("上传文件不能为空");
             }
             // 上传文件
-            String fileName = file.getOriginalFilename();
-            String suffix = fileName.substring(fileName.lastIndexOf("."));
+            String suffix = OssUploadSecurityUtils.validateAndGetDotSuffix(file);
             CloudStorageService storage = OSSFactory.build();
             String url = storage.uploadSuffix(file.getBytes(), suffix);
             vo.setErrno(0);

+ 58 - 0
fs-common/src/main/java/com/fs/common/utils/security/OssUploadSecurityUtils.java

@@ -0,0 +1,58 @@
+package com.fs.common.utils.security;
+
+import com.fs.common.exception.file.InvalidExtensionException;
+import com.fs.common.exception.file.OssException;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.file.FileUploadUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * OSS 上传安全校验:后缀白名单,禁止可执行/脚本类型
+ */
+public final class OssUploadSecurityUtils {
+
+    /**
+     * 业务允许的安全后缀(不含 html/htm/jsp 等危险类型)
+     */
+    public static final String[] SAFE_ALLOWED_EXTENSION = {
+            // 图片
+            "bmp", "gif", "jpg", "jpeg", "png", "webp",
+            // 文档
+            "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "pdf",
+            // 压缩
+            "rar", "zip", "gz", "bz2",
+            // 音视频
+            "mp3", "wav", "mp4", "avi", "rmvb"
+    };
+
+    private OssUploadSecurityUtils() {
+    }
+
+    /**
+     * 校验文件并返回带点后缀,如 .jpg
+     */
+    public static String validateAndGetDotSuffix(MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            throw new OssException("上传文件不能为空");
+        }
+        String originalFilename = file.getOriginalFilename();
+        if (StringUtils.isEmpty(originalFilename)) {
+            throw new OssException("文件名不能为空");
+        }
+        if (originalFilename.contains("..") || originalFilename.contains("/") || originalFilename.contains("\\")) {
+            throw new OssException("文件名非法");
+        }
+        try {
+            FileUploadUtils.assertAllowed(file, SAFE_ALLOWED_EXTENSION);
+        } catch (InvalidExtensionException e) {
+            throw new OssException("不支持的文件类型: " + FileUploadUtils.getExtension(file));
+        } catch (Exception e) {
+            throw new OssException("文件校验失败: " + e.getMessage());
+        }
+        String extension = FileUploadUtils.getExtension(file);
+        if (StringUtils.isEmpty(extension)) {
+            throw new OssException("无法识别文件后缀");
+        }
+        return "." + extension.toLowerCase();
+    }
+}

+ 58 - 0
fs-common/src/main/java/com/fs/common/utils/security/UrlSecurityUtils.java

@@ -0,0 +1,58 @@
+package com.fs.common.utils.security;
+
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+
+import java.net.InetAddress;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * 对外 URL 访问安全校验,防止 SSRF
+ */
+public final class UrlSecurityUtils {
+
+    private static final Set<String> ALLOWED_PROTOCOLS = new HashSet<String>(Arrays.asList("http", "https"));
+
+    private UrlSecurityUtils() {
+    }
+
+    /**
+     * 校验 URL:协议白名单 + 拒绝内网/回环/链路本地地址
+     */
+    public static void validatePublicHttpUrl(String urlStr) {
+        if (StringUtils.isEmpty(urlStr)) {
+            throw new ServiceException("URL 不能为空");
+        }
+        try {
+            URL url = new URL(urlStr);
+            String protocol = url.getProtocol() == null ? "" : url.getProtocol().toLowerCase(Locale.ROOT);
+            if (!ALLOWED_PROTOCOLS.contains(protocol)) {
+                throw new ServiceException("仅允许 HTTP/HTTPS 协议");
+            }
+            String host = url.getHost();
+            if (StringUtils.isEmpty(host)) {
+                throw new ServiceException("URL 主机非法");
+            }
+            InetAddress address = InetAddress.getByName(host);
+            if (address.isAnyLocalAddress()
+                    || address.isLoopbackAddress()
+                    || address.isLinkLocalAddress()
+                    || address.isSiteLocalAddress()
+                    || address.isMulticastAddress()) {
+                throw new ServiceException("禁止访问内网或本地地址");
+            }
+            String ip = address.getHostAddress();
+            if (ip.startsWith("169.254.") || "0.0.0.0".equals(ip) || "::1".equals(ip)) {
+                throw new ServiceException("禁止访问内网或本地地址");
+            }
+        } catch (ServiceException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new ServiceException("非法 URL: " + e.getMessage());
+        }
+    }
+}

+ 4 - 4
fs-company-app/src/main/java/com/fs/app/controller/CommonController.java

@@ -12,6 +12,7 @@ import com.fs.common.config.FSConfig;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
 import com.fs.common.exception.file.OssException;
+import com.fs.common.utils.security.OssUploadSecurityUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.file.FileUploadUtils;
 import com.fs.company.domain.CompanyUser;
@@ -221,10 +222,9 @@ public class CommonController extends AppBaseController {
 			throw new OssException("上传文件不能为空");
 		}
 		// 上传文件
-		String fileName = file.getOriginalFilename();
-		String suffix = fileName.substring(fileName.lastIndexOf("."));
-		CloudStorageService storage = OSSFactory.build();
-		String url = storage.uploadSuffix(file.getBytes(), suffix);
+		String suffix = OssUploadSecurityUtils.validateAndGetDotSuffix(file);
+        CloudStorageService storage = OSSFactory.build();
+        String url = storage.uploadSuffix(file.getBytes(), suffix);
 		return R.ok().put("url",url);
 	}
 

+ 3 - 0
fs-company-app/src/main/java/com/fs/app/controller/CompanyUserController.java

@@ -478,6 +478,9 @@ public class CompanyUserController extends AppBaseController {
         companyUser.setUserId(userId);
         companyUser.setVoicePrintUrl(param.getVoicePrintUrl());
 
+        // SSRF 防护:校验声纹 URL,禁止内网地址
+        com.fs.common.utils.security.UrlSecurityUtils.validatePublicHttpUrl(param.getVoicePrintUrl());
+
         //转换音频格式 mp3-wav
         String s = AudioUtils.audioWAVFromUrl(param.getVoicePrintUrl());
 

+ 3 - 3
fs-company/src/main/java/com/fs/chat/controller/ChatUploadController.java

@@ -10,6 +10,7 @@ import com.fs.common.core.domain.R;
 import com.fs.common.exception.file.OssException;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.file.FileUploadUtils;
+import com.fs.common.utils.security.OssUploadSecurityUtils;
 import com.fs.company.domain.CompanyConfig;
 import com.fs.company.service.ICompanyConfigService;
 import com.fs.framework.security.LoginUser;
@@ -72,9 +73,8 @@ public class ChatUploadController extends BaseController
         fileDTO.setMedia_id(vo.getMedia_id());
         String url=weixinKfService.getFile(qwConfig.getCorpId(),qwConfig.getSecret(),fileDTO);
         vo.setUrl(url);
-        // 上传文件
-        String fileName = file.getOriginalFilename();
-        String suffix = fileName.substring(fileName.lastIndexOf("."));
+        // 上传文件(后缀白名单校验)
+        String suffix = OssUploadSecurityUtils.validateAndGetDotSuffix(file);
         CloudStorageService storage = OSSFactory.build();
         String ossUrl = storage.uploadSuffix(file.getBytes(), suffix);
         return R.ok().put("data",vo).put("ossUrl",ossUrl);

+ 9 - 10
fs-company/src/main/java/com/fs/company/controller/common/CommonController.java

@@ -6,6 +6,7 @@ import com.fs.common.constant.Constants;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
 import com.fs.common.exception.file.OssException;
+import com.fs.common.utils.security.OssUploadSecurityUtils;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.StringUtils;
@@ -229,8 +230,7 @@ public class CommonController
             throw new OssException("上传文件不能为空");
         }
         // 上传文件
-        String fileName = file.getOriginalFilename();
-        String suffix = fileName.substring(fileName.lastIndexOf("."));
+        String suffix = OssUploadSecurityUtils.validateAndGetDotSuffix(file);
         CloudStorageService storage = OSSFactory.build();
         String url = storage.uploadSuffix(file.getBytes(), suffix);
         return R.ok().put("url",url);
@@ -244,11 +244,11 @@ public class CommonController
             throw new OssException("上传文件不能为空");
         }
         // 上传文件
-        String fileName = file.getOriginalFilename();
-        String suffix = fileName.substring(fileName.lastIndexOf("."));
-        String prefix = fileName.substring(0, fileName.lastIndexOf("."));
+        String suffix = OssUploadSecurityUtils.validateAndGetDotSuffix(file);
+        // 禁止使用原始文件名构造 OSS 路径,避免路径穿越
         CloudStorageService storage = OSSFactory.build();
-        String url = storage.upload(file.getBytes(), prefix+System.currentTimeMillis()+suffix);
+        String url = storage.uploadSuffix(file.getBytes(), suffix);
+        String fileName = file.getOriginalFilename();
         return R.ok().put("url",url).put("fileName",fileName);
     }
 
@@ -314,10 +314,9 @@ public class CommonController
                 throw new OssException("上传文件不能为空");
             }
             // 上传文件
-            String fileName = file.getOriginalFilename();
-            String suffix = fileName.substring(fileName.lastIndexOf("."));
-            CloudStorageService storage = OSSFactory.build();
-            String url = storage.uploadSuffix(file.getBytes(), suffix);
+            String suffix = OssUploadSecurityUtils.validateAndGetDotSuffix(file);
+        CloudStorageService storage = OSSFactory.build();
+        String url = storage.uploadSuffix(file.getBytes(), suffix);
             vo.setErrno(0);
             List<WangUploadVO.WangUploadItem> items=new ArrayList<>();
             WangUploadVO.WangUploadItem item=new WangUploadVO.WangUploadItem();

+ 4 - 4
fs-doctor-app/src/main/java/com/fs/app/controller/CommonController.java

@@ -16,6 +16,7 @@ import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.file.OssException;
+import com.fs.common.utils.security.OssUploadSecurityUtils;
 import com.fs.common.utils.sign.Base64;
 import com.fs.common.utils.uuid.IdUtils;
 import com.fs.his.config.FsSysConfig;
@@ -168,10 +169,9 @@ public class CommonController {
 			throw new OssException("上传文件不能为空");
 		}
 		// 上传文件
-		String fileName = file.getOriginalFilename();
-		String suffix = fileName.substring(fileName.lastIndexOf("."));
-		CloudStorageService storage = OSSFactory.build();
-		String url = storage.uploadSuffix(file.getBytes(), suffix);
+		String suffix = OssUploadSecurityUtils.validateAndGetDotSuffix(file);
+        CloudStorageService storage = OSSFactory.build();
+        String url = storage.uploadSuffix(file.getBytes(), suffix);
 		return R.ok().put("url",url);
 	}
 

+ 6 - 3
fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoServiceImpl.java

@@ -10,6 +10,7 @@ import java.util.stream.Collectors;
 
 import com.alibaba.fastjson.JSON;
 import com.fs.common.core.domain.R;
+import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.bean.BeanUtils;
@@ -478,10 +479,12 @@ public class FsUserVideoServiceImpl implements IFsUserVideoService {
         log.info("上传视频开始",uploadId);
         File tempFile = null;
         try {
-            // 将 MultipartFile 转换为临时文件
+            // 将 MultipartFile 转换为临时文件(固定 .tmp 后缀,禁止用户可控后缀落盘)
             String originalFilename = file.getOriginalFilename();
-            String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
-            tempFile = File.createTempFile("upload_", suffix);
+            if (originalFilename == null || !originalFilename.toLowerCase().endsWith(".mp4")) {
+                throw new ServiceException("仅支持 MP4 视频格式");
+            }
+            tempFile = File.createTempFile("upload_", ".tmp");
             file.transferTo(tempFile);
 
             // 点播空间名称

+ 5 - 11
fs-service/src/main/java/com/fs/fastGpt/service/impl/FastGptCollectionServiceImpl.java

@@ -348,18 +348,12 @@ public class FastGptCollectionServiceImpl implements IFastGptCollectionService
      *  url转临时文件filr
      */
     public static File urlToFile(String fileUrl) throws Exception {
+        // SSRF 防护:协议白名单 + 拒绝内网地址
+        com.fs.common.utils.security.UrlSecurityUtils.validatePublicHttpUrl(fileUrl);
 
-        // 从URL中提取文件名,文件后缀
-        String fileExtension = getFileExtension(new File(fileUrl).getName());
-        String fileName = extractFileName(new URL(fileUrl))+new Date().getTime();
-        File tempFile=null;
-        if (!StringUtil.strIsNullOrEmpty(fileExtension)){
-            // 创建一个临时文件
-            tempFile = File.createTempFile(fileName, "."+fileExtension);
-        }else {
-            tempFile = File.createTempFile(fileName, null);
-        }
-
+        // 临时文件统一使用固定 .tmp 后缀,避免 URL 后缀可控
+        String fileName = "fastgpt_" + new Date().getTime();
+        File tempFile = File.createTempFile(fileName, ".tmp");
 
         // 使用 try-with-resources 语句自动关闭资源
         try (BufferedInputStream in = new BufferedInputStream(new URL(fileUrl).openStream());

+ 5 - 2
fs-service/src/main/java/com/fs/fastgptApi/util/AudioUtils.java

@@ -209,6 +209,8 @@ public class AudioUtils {
 
     public static String audioWAVFromUrl(String audioUrl) {
         try {
+            // SSRF 防护:禁止访问内网/本地地址
+            com.fs.common.utils.security.UrlSecurityUtils.validatePublicHttpUrl(audioUrl);
             // 下载文件到本地临时路径
             File tempFile = downloadFileFromUrl(audioUrl);
             if (tempFile == null) {
@@ -637,8 +639,9 @@ public class AudioUtils {
              process = Runtime.getRuntime().exec("taskkill -f -t -im silk_v3_encoder.exe");
              */
             // 方法2,除了会弹出弹窗,没什么问题 cmd /c 极为重要,执行完毕后会自动关闭
-            process = Runtime.getRuntime().exec("cmd /c start  " + path + "silk_v3_encoder.exe " + pcmPath + " " + target + " -tencent");
-            process .waitFor();
+            ProcessBuilder pb = new ProcessBuilder("cmd", "/c", "start", "", path + "silk_v3_encoder.exe", pcmPath, target, "-tencent");
+            process = pb.start();
+            process.waitFor();
             Thread.sleep(1000);
             // 有更好的方法会后续慢慢更新..
         } catch (Exception e) {

+ 5 - 6
fs-store/src/main/java/com/fs/store/controller/common/CommonController.java

@@ -6,6 +6,7 @@ import com.fs.common.constant.Constants;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
 import com.fs.common.exception.file.OssException;
+import com.fs.common.utils.security.OssUploadSecurityUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.file.FileUploadUtils;
 import com.fs.common.utils.file.FileUtils;
@@ -133,8 +134,7 @@ public class CommonController
             throw new OssException("上传文件不能为空");
         }
         // 上传文件
-        String fileName = file.getOriginalFilename();
-        String suffix = fileName.substring(fileName.lastIndexOf("."));
+        String suffix = OssUploadSecurityUtils.validateAndGetDotSuffix(file);
         CloudStorageService storage = OSSFactory.build();
         String url = storage.uploadSuffix(file.getBytes(), suffix);
         return R.ok().put("url",url);
@@ -150,10 +150,9 @@ public class CommonController
                 throw new OssException("上传文件不能为空");
             }
             // 上传文件
-            String fileName = file.getOriginalFilename();
-            String suffix = fileName.substring(fileName.lastIndexOf("."));
-            CloudStorageService storage = OSSFactory.build();
-            String url = storage.uploadSuffix(file.getBytes(), suffix);
+            String suffix = OssUploadSecurityUtils.validateAndGetDotSuffix(file);
+        CloudStorageService storage = OSSFactory.build();
+        String url = storage.uploadSuffix(file.getBytes(), suffix);
             vo.setErrno(0);
             List<WangUploadVO.WangUploadItem> items=new ArrayList<>();
             WangUploadVO.WangUploadItem item=new WangUploadVO.WangUploadItem();

+ 4 - 4
fs-user-app/src/main/java/com/fs/app/controller/CommonController.java

@@ -26,6 +26,7 @@ import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.ResponseResult;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.file.OssException;
+import com.fs.common.utils.security.OssUploadSecurityUtils;
 import com.fs.common.utils.file.FileUploadUtils;
 import com.fs.common.utils.http.HttpUtils;
 import com.fs.common.utils.sign.Md5Utils;
@@ -355,10 +356,9 @@ public class CommonController {
 			throw new OssException("上传文件不能为空");
 		}
 		// 上传文件
-		String fileName = file.getOriginalFilename();
-		String suffix = fileName.substring(fileName.lastIndexOf("."));
-		CloudStorageService storage = OSSFactory.build();
-		String url = storage.uploadSuffix(file.getBytes(), suffix);
+		String suffix = OssUploadSecurityUtils.validateAndGetDotSuffix(file);
+        CloudStorageService storage = OSSFactory.build();
+        String url = storage.uploadSuffix(file.getBytes(), suffix);
 		return R.ok().put("url",url);
 	}
 

+ 15 - 7
fs-user-app/src/main/java/com/fs/app/controller/TalentController.java

@@ -161,7 +161,7 @@ public class TalentController extends  AppBaseController{
      * @return
      * @throws Exception
      */
-    //@Login
+    @Login
     @PostMapping("/uploadOSSTalent")
     public R uploadFile(@RequestParam("file") MultipartFile file) throws Exception {
         //校验文件是否为空
@@ -169,21 +169,29 @@ public class TalentController extends  AppBaseController{
             throw new OssException("上传文件不能为空");
         }
 
-        //获取文件基本信息
+        // 后缀白名单 + 路径穿越字符过滤
+        String suffix = com.fs.common.utils.security.OssUploadSecurityUtils.validateAndGetDotSuffix(file);
         String originalFilename = file.getOriginalFilename();
-        String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
         String fileType = file.getContentType();
 
         //如果是视频文件且需要缩略图
         if (fileType != null && fileType.startsWith("video/")) {
             // 3.1 校验视频格式(示例仅允许MP4)
-            if (!fileType.equals("video/mp4")) {
+            if (!".mp4".equalsIgnoreCase(suffix) && !fileType.equals("video/mp4")) {
                 return R.error("仅支持MP4视频格式");
             }
 
-            //保存临时视频文件
-            String videoFileName = System.currentTimeMillis() + "_" + originalFilename;
-            File videoFile = new File(VIDEO_UPLOAD_DIR, videoFileName);
+            // UUID 重命名,禁止使用原始文件名拼接本地路径(防路径穿越)
+            String videoFileName = UUID.randomUUID().toString().replace("-", "") + ".mp4";
+            File uploadDir = new File(VIDEO_UPLOAD_DIR);
+            if (!uploadDir.exists()) {
+                uploadDir.mkdirs();
+            }
+            File videoFile = new File(uploadDir, videoFileName);
+            // 二次确认落盘路径仍在约定目录内
+            if (!videoFile.getCanonicalPath().startsWith(uploadDir.getCanonicalPath())) {
+                throw new OssException("文件路径非法");
+            }
             file.transferTo(videoFile);
 
             //获取视频元信息(宽高、大小、时长等)