!16 修复:修复头像上传注入漏洞

* 修复:登录IP限流可绕过及代码评审问题
* 修复:修复头像上传注入漏洞
* 修复:修复LDAP登录过滤器注入漏洞
This commit is contained in:
白书科技 committed 2026-10-12 06:22:00 +00:00
1 parent 60df4f2b74
commit b3c709214f
7 files changed
+161 -17

No files matched your search

@@ -30,20 +30,40 @@ public class LoginLimitCache {
@Autowired private PlayEduConfig playEduConfig;
public void check(String email) throws ServiceException {
String limitKey = cacheKey(email);
Long reqCount = rateLimiterService.current(limitKey, 600L);
if (reqCount >= 10 && !playEduConfig.getTesting()) {
Long exp = MemoryCacheUtil.ttlWithoutPrefix(limitKey);
String msg = String.format("您的账号已被锁定,请%s后重试", exp > 60 ? exp / 60 + "分钟" : exp + "秒");
throw new ServiceException(msg);
}
checkKey(cacheKey(email), 10L, "您的账号已被锁定");
}
// 按客户端IP计数,避免旋转username绕过账号维度的限流
public void checkIp(String ip) throws ServiceException {
checkKey(ipCacheKey(ip), 30L, "登录尝试过于频繁");
}
public void destroy(String email) {
MemoryCacheUtil.del(cacheKey(email));
}
// 登录成功仅退还本次计数,不清零,防止攻击者用自有账号登录重置整个IP的失败计数
public void releaseIp(String ip) {
String limitKey = ipCacheKey(ip);
if (MemoryCacheUtil.exists(limitKey)) {
MemoryCacheUtil.increment(limitKey, -1L, 600L);
}
}
private void checkKey(String limitKey, long limit, String reason) throws ServiceException {
Long reqCount = rateLimiterService.current(limitKey, 600L);
if (reqCount >= limit && !playEduConfig.getTesting()) {
Long exp = MemoryCacheUtil.ttlWithoutPrefix(limitKey);
String msg = String.format("%s,请%s后重试", reason, exp > 60 ? exp / 60 + "分钟" : exp + "秒");
throw new ServiceException(msg);
}
}
private String cacheKey(String email) {
return "login-limit:" + email;
}
private String ipCacheKey(String ip) {
return "login-limit-ip:" + ip;
}
}
@@ -69,6 +69,10 @@ public class LoginController {
return JsonResponse.error("请使用LDAP登录");
}
// IP维度限流需在查询用户之前执行,否则不存在的邮箱会绕过计数
String ip = IpUtil.getClientIp();
loginLimitCache.checkIp(ip);
String email = req.getEmail();
User user = userService.find(email);
@@ -87,6 +91,7 @@ public class LoginController {
}
loginLimitCache.destroy(email);
loginLimitCache.releaseIp(ip);
return JsonResponse.data(loginBus.tokenByUser(user));
}
@@ -107,6 +112,8 @@ public class LoginController {
}
// 限流控制
String ip = IpUtil.getClientIp();
loginLimitCache.checkIp(ip);
loginLimitCache.check(username);
// 锁控制-防止并发登录重复写入数据
@@ -125,6 +132,7 @@ public class LoginController {
// 删除限流控制
loginLimitCache.destroy(username);
loginLimitCache.releaseIp(ip);
return JsonResponse.data(data);
} catch (ServiceException e) {
@@ -15,6 +15,7 @@
*/
package xyz.playedu.api.controller.frontend;
import java.io.InputStream;
import java.util.*;
import java.util.stream.Collectors;
import lombok.SneakyThrows;
@@ -25,6 +26,7 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import xyz.playedu.api.request.frontend.ChangePasswordRequest;
import xyz.playedu.common.constant.BackendConstant;
import xyz.playedu.common.constant.CommonConstant;
import xyz.playedu.common.constant.FrontendConstant;
import xyz.playedu.common.context.FCtx;
@@ -38,6 +40,7 @@ import xyz.playedu.common.types.JsonResponse;
import xyz.playedu.common.types.UploadFileInfo;
import xyz.playedu.common.types.config.S3Config;
import xyz.playedu.common.types.mapper.UserCourseHourRecordCourseCountMapper;
import xyz.playedu.common.util.HelperUtil;
import xyz.playedu.common.util.StringUtil;
import xyz.playedu.course.domain.*;
import xyz.playedu.course.service.*;
@@ -100,8 +103,33 @@ public class UserController {
return JsonResponse.data(data);
}
@SneakyThrows
@PutMapping("/avatar")
public JsonResponse changeAvatar(MultipartFile file) {
if (file == null || file.isEmpty() || StringUtil.isEmpty(file.getOriginalFilename())) {
throw new ServiceException("请上传文件");
}
// 扩展名
String ext = HelperUtil.fileExt(file.getOriginalFilename());
// 头像仅支持图片格式
if (!BackendConstant.RESOURCE_TYPE_IMAGE.equals(
BackendConstant.RESOURCE_EXT_2_TYPE.get(ext))) {
return JsonResponse.error("当前格式不支持");
}
// 头像大小限制
if (file.getSize() > FrontendConstant.AVATAR_MAX_SIZE) {
return JsonResponse.error("头像大小不能超过5MB");
}
// 校验文件头(魔数)与扩展名一致
byte[] header;
try (InputStream in = file.getInputStream()) {
header = in.readNBytes(8);
}
if (!HelperUtil.matchImageHeader(header, ext)) {
return JsonResponse.error("当前格式不支持");
}
// 校验存储配置是否完整
S3Config s3Config = appConfigService.getS3Config();
if (StringUtil.isEmpty(s3Config.getAccessKey())
@@ -40,4 +40,6 @@ public class FrontendConstant {
public static final String USER_UPLOAD_IMAGE_SCENE_AVATAR = "avatar";
public static final String DIR_AVATAR = "user/avatar/";
public static final int AVATAR_MAX_SIZE = 5 * 1024 * 1024;
}
@@ -185,4 +185,40 @@ public class HelperUtil {
String[] array = filename.split("\\.");
return array[array.length - 1].toLowerCase();
}
/**
* 校验文件头部(魔数)与图片扩展名是否匹配
*
* @param header 文件头部字节,建议至少8字节
* @param extension 文件扩展名,小写,不含点
* @return boolean
*/
public static boolean matchImageHeader(byte[] header, String extension) {
if (header == null || extension == null) {
return false;
}
switch (extension) {
case "png":
return startsWith(header, 0, 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A);
case "jpg":
case "jpeg":
return startsWith(header, 0, 0xFF, 0xD8, 0xFF);
case "gif":
return startsWith(header, 0, 0x47, 0x49, 0x46, 0x38);
default:
return false;
}
}
private static boolean startsWith(byte[] data, int offset, int... prefix) {
if (data.length < offset + prefix.length) {
return false;
}
for (int i = 0; i < prefix.length; i++) {
if ((data[offset + i] & 0xFF) != prefix[i]) {
return false;
}
}
return true;
}
}
@@ -87,6 +87,47 @@ public class IpUtil {
return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : getMultistageReverseProxyIp(ip);
}
/**
* 获取可信的客户端IP,用于限流等安全场景
*
* <p>getIpAddress 直接信任客户端可伪造的请求头,不能用于安全判断。
*
* <p>这里以TCP连接的remoteAddr为起点,仅当其为内网/回环地址(受信代理)时才从X-Forwarded-For最右侧逐级回溯,
* 遇到第一个非受信地址即为客户端IP,客户端在X-Forwarded-For左侧伪造的值不会被采用。
*
* @return IP地址
*/
public static String getClientIp() {
HttpServletRequest request = RequestUtil.handler();
if (request == null) {
return "unknown";
}
String ip = request.getRemoteAddr();
String xff = request.getHeader("X-Forwarded-For");
if (StringUtil.isNotBlank(xff)) {
String[] hops = xff.split(",");
for (int i = hops.length - 1; i >= 0 && isTrustedProxy(ip); i--) {
String hop = hops[i].trim();
if (isUnknown(hop)) {
break;
}
ip = hop;
}
}
return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
}
private static boolean isTrustedProxy(String ip) {
if (ip == null) {
return false;
}
if ("0:0:0:0:0:0:0:1".equals(ip) || "::1".equals(ip)) {
return true;
}
byte[] address = textToNumericFormatV4(ip);
return address != null && (address[0] == 0x7F || internalIp(address));
}
/**
* 根据IP获取所在地址
*
@@ -236,12 +236,15 @@ public class LdapUtil {
controls.setReturningObjFlag(true);
controls.setCountLimit(1);
String userFilter = "";
// 使用JNDI参数化过滤器,由JNDI按RFC 4515转义用户输入,防止过滤器注入
String userFilter;
Object[] filterArgs;
if (StringUtil.isNotEmpty(mail)) {
userFilter =
String.format("(|(mail=%s)(email=%s)(userPrincipalName=%s))", mail, mail, mail);
} else if (StringUtil.isNotEmpty(uid)) {
userFilter = String.format("(|(uid=%s)(sAMAccountName=%s))", uid, uid);
userFilter = "(|(mail={0})(email={0})(userPrincipalName={0}))";
filterArgs = new Object[] {mail};
} else {
userFilter = "(|(uid={0})(sAMAccountName={0}))";
filterArgs = new Object[] {uid};
}
String filter = String.format("(&%s%s)", userFilter, USER_OBJECT_CLASS);
@@ -251,7 +254,7 @@ public class LdapUtil {
ldapConfig.getUrl(), ldapConfig.getAdminUser(), ldapConfig.getAdminPass());
NamingEnumeration<SearchResult> result = null;
try {
result = ldapContext.search(ldapConfig.getBaseDN(), filter, controls);
result = ldapContext.search(ldapConfig.getBaseDN(), filter, filterArgs, controls);
} catch (NamingException e) {
log.error("LDAP-通过mail或uid登录失败", e);
} finally {
@@ -326,12 +329,18 @@ public class LdapUtil {
if (attributes.get("uSNCreated") != null) {
// Window AD域
ldapUser.setId((String) attributes.get("uSNCreated").get());
ldapUser.setUid((String) attributes.get("sAMAccountName").get());
ldapUser.setId(getAttribute(attributes, "uSNCreated"));
ldapUser.setUid(getAttribute(attributes, "sAMAccountName"));
} else {
// OpenLDAP
ldapUser.setId((String) attributes.get("entryUUID").get());
ldapUser.setUid((String) attributes.get("uid").get());
ldapUser.setId(getAttribute(attributes, "entryUUID"));
ldapUser.setUid(getAttribute(attributes, "uid"));
}
// 关键属性缺失时按无效用户处理,避免异常信息暴露条目属性差异
if (StringUtil.isEmpty(ldapUser.getId()) || StringUtil.isEmpty(ldapUser.getUid())) {
log.info("LDAP-用户属性缺失|id={}|uid={}", ldapUser.getId(), ldapUser.getUid());
return null;
}
// ou计算