mirror of
https://github.com/PlayEdu/PlayEdu
synced 2026-10-25 17:36:35 +08:00
!16 修复:修复头像上传注入漏洞
* 修复:登录IP限流可绕过及代码评审问题 * 修复:修复头像上传注入漏洞 * 修复:修复LDAP登录过滤器注入漏洞
This commit is contained in:
1 parent
60df4f2b74
commit
b3c709214f
7 files changed
+161
-17
No files matched your search
+27
-7
@@ -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;
|
||||
}
|
||||
}
|
||||
+8
@@ -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) {
|
||||
|
||||
+28
@@ -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())
|
||||
|
||||
+2
@@ -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获取所在地址
|
||||
*
|
||||
|
||||
+19
-10
@@ -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计算
|
||||
|
||||
Reference in new issue
Block a user