Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions changeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 站点都不校验证书, 现在会。如果有服务端用的是自签名或过期证书, 升级后会连接失败 —— 这正是该被暴露出来的问题, 正确的做法是把该证书加进信任库, 而不是关掉校验。
58 changes: 40 additions & 18 deletions core/src/main/java/info/xiaomo/core/utils/FileUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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.
*
Expand Down Expand Up @@ -256,7 +269,7 @@
* @deprecated 在实现的时候没有注意到File类本身带一个toURL方法将文件路径转换为URL。
* 请使用File.toURL方法。
*/
public static URL getURL(File file) throws MalformedURLException {

Check warning on line 272 in core/src/main/java/info/xiaomo/core/utils/FileUtil.java

View workflow job for this annotation

GitHub Actions / Build (JDK 21)

deprecated item is not annotated with @deprecated

Check warning on line 272 in core/src/main/java/info/xiaomo/core/utils/FileUtil.java

View workflow job for this annotation

GitHub Actions / Build (JDK 25)

deprecated item is not annotated with @deprecated
String fileURL = "file:/" + file.getAbsolutePath();
return new URL(fileURL);
}
Expand Down Expand Up @@ -661,30 +674,23 @@
* @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;
Expand Down Expand Up @@ -826,12 +832,28 @@
return null;
}

/**
* 由日期、邮箱前缀和文件类型拼出新文件名。
*
* <p>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)
Expand Down
60 changes: 11 additions & 49 deletions core/src/main/java/info/xiaomo/core/utils/HttpUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -62,23 +57,16 @@ private static HttpURLConnection initHttp(String urlStr, String method, Map<Stri
}

/**
* 初始化http请求参数
* 初始化 https 请求参数。证书与主机名校验一律交给 JDK 默认实现。
*
* @throws IOException
* @throws NoSuchAlgorithmException
* @throws NoSuchProviderException
* @throws KeyManagementException
* @throws IOException IOException
*/
private static HttpsURLConnection initHttps(String urlStr, String method, Map<String, String> 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<String, String> 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);
// 读取超时 --服务器响应比较慢,增大时间
Expand All @@ -91,7 +79,6 @@ private static HttpsURLConnection initHttps(String urlStr, String method, Map<St
http.setRequestProperty(entry.getKey(), entry.getValue());
}
}
http.setSSLSocketFactory(ssf);
http.setDoOutput(true);
http.setDoInput(true);
http.connect();
Expand Down Expand Up @@ -275,8 +262,12 @@ public static void setCookie(HttpServletResponse response, String name, String v
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setMaxAge(maxAgeInSeconds);
// 指定为httpOnly保证安全性
// httpOnly: 禁止 JavaScript 读取, 降低 XSS 窃取 cookie 的风险
cookie.setHttpOnly(true);
// secure: 只允许通过 https 发送, 避免明文链路上被嗅探。本地用 http 调试时该 cookie 不会被浏览器回传
cookie.setSecure(true);
// 限制跨站发送, 缓解 CSRF
cookie.setAttribute("SameSite", "Lax");
response.addCookie(cookie);
}

Expand All @@ -290,33 +281,4 @@ public static String getUserAgent(HttpServletRequest request) {
return request.getHeader("User-Agent");
}

/**
* https 域名校验
*/
public class TrustAnyHostnameVerifier implements HostnameVerifier {
@Override
public boolean verify(String hostname, SSLSession session) {
return true;
}
}


}

class MyX509TrustManager implements X509TrustManager {

@Override
public X509Certificate[] getAcceptedIssuers() {
return null;
}

@Override
public void checkClientTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
}

@Override
public void checkServerTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
}
}
15 changes: 9 additions & 6 deletions core/src/main/java/info/xiaomo/core/utils/RandomUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -66,30 +66,33 @@ public static <T> T randomElement(Collection<T> collection) {


/**
* 生成一个10位的tonken用于http cache(纯数字)
* 生成一个10位的token用于http cache(纯数字)
*
* <p>用 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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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();
}
}

}
Loading