diff --git a/changeLog.md b/changeLog.md index 480f1fb..4bcf1a9 100644 --- a/changeLog.md +++ b/changeLog.md @@ -79,3 +79,17 @@ LocalVariableTableParameterNameDiscoverer, 因此未显式命名的 @PathVariabl 在 spring boot 4 下会直接抛 IllegalArgumentException。项目中共有 40 处这样的写法, 编译期没有任何提示。spring-boot-starter-parent 默认会加 -parameters, 但本项目是导入 BOM 而非继承 parent, 需要在 maven-compiler-plugin 中自行配置。 + +- 2026-09-09 修复 CodeQL 报出的安全问题 + +``` +1. HttpUtil: 移除"接受任何证书"的 TrustManager 和"永远返回 true"的 HostnameVerifier, + TLS 证书链与主机名校验交回 JDK 默认实现 +2. HttpUtil.setCookie: 补上 secure 与 SameSite=Lax +3. FileUtil: 上传文件名先过滤路径字符, 落盘路径用 normalize + startsWith 限制在 upload 目录内 +4. RandomUtil: token / 密码 / 盐值改用 RandomStringUtils.secure()(SecureRandom 支撑), + 原先的 java.util.Random 种子只有 48 位且算法公开, 可预测 +5. 新增 SecurityHardeningTest 覆盖以上各项, 防止改回去 +``` + +第 1 条是行为变更: 之前连接任何 https 站点都不校验证书, 现在会。如果有服务端用的是自签名或过期证书, 升级后会连接失败 —— 这正是该被暴露出来的问题, 正确的做法是把该证书加进信任库, 而不是关掉校验。 diff --git a/core/src/main/java/info/xiaomo/core/utils/FileUtil.java b/core/src/main/java/info/xiaomo/core/utils/FileUtil.java index d3125fe..0e8e965 100644 --- a/core/src/main/java/info/xiaomo/core/utils/FileUtil.java +++ b/core/src/main/java/info/xiaomo/core/utils/FileUtil.java @@ -2,11 +2,16 @@ import info.xiaomo.core.constant.FileConst; import info.xiaomo.core.constant.SymbolConst; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.web.multipart.MultipartFile; import java.io.*; import java.net.MalformedURLException; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; @@ -20,6 +25,14 @@ */ public class FileUtil { + + private static final Logger LOGGER = LoggerFactory.getLogger(FileUtil.class); + + /** + * 上传文件的存放目录。原先这里是空串, 等于把文件写进进程的工作目录。 + */ + private static final String UPLOAD_DIR = "upload"; + /** * Buffer size when reading from input stream. * @@ -661,30 +674,23 @@ private static void list(List list, File file, * @return fileUrl */ public static String upload(MultipartFile file, String email) { - String savePath = ""; String filename = ""; if (file != null && !file.isEmpty()) { // 获取图片的文件名 String fileName = file.getOriginalFilename(); - // 重新定义图片名字 + // 重新定义图片名字, 其中的用户输入已经过滤过路径字符 filename = FileUtil.getNewFileName(fileName, email); - //上传服务器上 新文件路径 - String os = System.getProperty("os.name").toLowerCase(); try { - // 判断服务器上 文件夹是否存在 - File newFile = new File(savePath); - if (!newFile.exists()) { - boolean result = newFile.mkdirs(); - System.out.println(result); + Path root = Paths.get(UPLOAD_DIR).toAbsolutePath().normalize(); + Files.createDirectories(root); + // normalize 折叠掉 .. , startsWith 确保最终路径不会逃出 root, 这是防目录穿越的标准写法 + Path target = root.resolve(filename).normalize(); + if (!target.startsWith(root)) { + throw new IOException("非法的文件名: " + filename); } - savePath = savePath + filename; - FileOutputStream out = new FileOutputStream(savePath); - // 写入文件 - out.write(file.getBytes()); - out.flush(); - out.close(); + Files.write(target, file.getBytes()); } catch (Exception e) { - e.printStackTrace(); + LOGGER.error("上传文件失败: {}", filename, e); } } return filename; @@ -826,12 +832,28 @@ public static FileConst getType(String filePath) throws IOException { return null; } + /** + * 由日期、邮箱前缀和文件类型拼出新文件名。 + * + *

fileName 与 email 都来自用户输入, 拼出来的结果会被当作路径使用, 因此两段都要先过滤掉 + * 路径分隔符和 . 之类的字符, 否则形如 {@code a.b/../../evil} 的文件名会把整段路径带进来。 + */ public static String getNewFileName(String fileName, String email) { - String fileType = FileUtil.getFileType(fileName); - String newName = email.split(SymbolConst.AT)[0]; + String fileType = sanitizeNamePart(FileUtil.getFileType(fileName)); + String newName = sanitizeNamePart(email.split(SymbolConst.AT)[0]); return (TimeUtil.getDateNow(TimeUtil.DATE_FORMAT_STRING) + SymbolConst.HENGXIAN + newName + SymbolConst.DIAN + fileType).toLowerCase(); } + /** + * 只保留字母、数字、下划线和连字符。路径分隔符、点、以及其它任何可能改变路径含义的字符一律丢弃。 + */ + private static String sanitizeNamePart(String part) { + if (part == null) { + return ""; + } + return part.replaceAll("[^A-Za-z0-9_-]", ""); + } + public static boolean isImage(String imageName) { String fileType = FileUtil.getFileType(imageName); return !("bmp".equals(fileType) || "BMP".equals(fileType) diff --git a/core/src/main/java/info/xiaomo/core/utils/HttpUtil.java b/core/src/main/java/info/xiaomo/core/utils/HttpUtil.java index acf56a6..e888753 100644 --- a/core/src/main/java/info/xiaomo/core/utils/HttpUtil.java +++ b/core/src/main/java/info/xiaomo/core/utils/HttpUtil.java @@ -11,11 +11,6 @@ import java.net.HttpURLConnection; import java.net.URL; import java.net.URLEncoder; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; import java.util.Map; import java.util.Map.Entry; @@ -62,23 +57,16 @@ private static HttpURLConnection initHttp(String urlStr, String method, Map headers) throws IOException, NoSuchAlgorithmException, NoSuchProviderException, KeyManagementException { - TrustManager[] tm = {new MyX509TrustManager()}; - SSLContext sslContext = SSLContext.getInstance("SSL", "SunJSSE"); - sslContext.init(null, tm, new java.security.SecureRandom()); - // 从上述SSLContext对象中得到SSLSocketFactory对象 - SSLSocketFactory ssf = sslContext.getSocketFactory(); + private static HttpsURLConnection initHttps(String urlStr, String method, Map headers) throws IOException { URL url = new URL(urlStr); HttpsURLConnection http = (HttpsURLConnection) url.openConnection(); - // 设置域名校验 - http.setHostnameVerifier(new HttpUtil().new TrustAnyHostnameVerifier()); + // 不覆盖 SSLSocketFactory 与 HostnameVerifier, 使用 JDK 默认实现: + // 默认实现会校验证书链和主机名。此处原先装的是"接受任何证书"的 TrustManager 和 + // "永远返回 true"的 HostnameVerifier, 等于完全关闭了 TLS 校验, 任何中间人都能解密和篡改流量。 // 连接超时 http.setConnectTimeout(25000); // 读取超时 --服务器响应比较慢,增大时间 @@ -91,7 +79,6 @@ private static HttpsURLConnection initHttps(String urlStr, String method, Map T randomElement(Collection collection) { /** - * 生成一个10位的tonken用于http cache(纯数字) + * 生成一个10位的token用于http cache(纯数字) + * + *

用 secure() 而不是 random(): 后者底层是 java.util.Random, 种子只有 48 位且算法公开, + * 观察到少量输出就能推算出后续全部结果。token、密码、盐值这类东西一旦可预测, 相当于形同虚设。 * * @return String 返回类型(纯数字) */ public static String getTonken() { - return RandomStringUtils.random(10, NUM_S); + return RandomStringUtils.secure().next(10, NUM_S); } /** - * 生成随机数 + * 生成随机密码。使用密码学安全的随机源。 * * @return String 返回类型 */ public static String randomPwd(int count) { - return RandomStringUtils.random(count, STR_S); + return RandomStringUtils.secure().next(count, STR_S); } /** - * 生成随机数 + * 生成随机密码。使用密码学安全的随机源。 * * @return String 返回类型 */ public static String randomPwd() { - return RandomStringUtils.random(10, STR_S); + return randomPwd(10); } /** diff --git a/core/src/test/java/info/xiaomo/core/utils/SecurityHardeningTest.java b/core/src/test/java/info/xiaomo/core/utils/SecurityHardeningTest.java new file mode 100644 index 0000000..494d2e8 --- /dev/null +++ b/core/src/test/java/info/xiaomo/core/utils/SecurityHardeningTest.java @@ -0,0 +1,71 @@ +package info.xiaomo.core.utils; + +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.HashSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 覆盖 CodeQL 报出的几处安全问题, 防止改回去。 + */ +class SecurityHardeningTest { + + @Test + void 文件名中的路径穿越片段应当被过滤掉() { + // 扩展名取的是最后一个点之后的内容, 不过滤的话整段路径都会被带进文件名 + String name = FileUtil.getNewFileName("a.b/../../evil", "someone@xiaomo.info"); + + assertThat(name).doesNotContain("..").doesNotContain("/").doesNotContain("\\"); + } + + @Test + void 邮箱前缀中的路径字符应当被过滤掉() { + String name = FileUtil.getNewFileName("photo.png", "../../etc/passwd@xiaomo.info"); + + assertThat(name).doesNotContain("..").doesNotContain("/"); + assertThat(name).endsWith(".png"); + } + + @Test + void 正常的文件名与邮箱应当保持可读() { + String name = FileUtil.getNewFileName("photo.PNG", "xiao-mo_1@xiaomo.info"); + + assertThat(name).contains("xiao-mo_1").endsWith(".png"); + } + + @Test + void cookie应当同时带上secure与httpOnly() { + MockHttpServletResponse response = new MockHttpServletResponse(); + + HttpUtil.setCookie(response, "uid", "42", 3600); + + Cookie cookie = response.getCookie("uid"); + assertThat(cookie).isNotNull(); + assertThat(cookie.getSecure()).as("secure 标志").isTrue(); + assertThat(cookie.isHttpOnly()).as("httpOnly 标志").isTrue(); + } + + @Test + void 盐值应当有足够长度且不重复() { + Set salts = new HashSet<>(); + for (int i = 0; i < 500; i++) { + String salt = RandomUtil.createSalt(); + assertThat(salt).hasSize(10); + salts.add(salt); + } + // 500 次取值不应出现碰撞, 否则说明随机源的取值空间或质量有问题 + assertThat(salts).hasSize(500); + } + + @Test + void token应当是纯数字且长度固定() { + for (int i = 0; i < 50; i++) { + assertThat(RandomUtil.getTonken()).hasSize(10).containsOnlyDigits(); + } + } + +}