文章目录
- OverView
- 客户端的计算不可信
- 1. 初始错误示例
- 2. 改进后的正确示例
- 3. 更进一步的优化:使用简化的请求对象
- 客户端提交的参数需要校验
- 初始错误示例
- 改进后的正确示例
- 方式一:手动校验参数
- 方式二:使用 Spring Validation 进行参数校验
- 隐藏域数据的风险
- 建议
- 不能信任请求头里的任何内容
- 初始实现
- 存在问题
- 改进建议
- 改进后的实现示例
- 建议
- 用户标识不能从客户端获取
- 问题描述
- 安全的用户身份验证方案
- 代码实现示例
- 方案一: 用户登录
- 方案二: 开发小技巧
- 自定义注解 `@LoginRequired`
- 自定义参数解析器 `LoginRequiredArgumentResolver`
- 将参数解析器添加到Spring上下文中
- 使用 `@LoginRequired` 注解的Controller方法
- 建议
- 总结
OverView
-
客户端的计算不可信:即使客户端计算了数据,服务端也必须重新计算并校验,以防止恶意篡改数据。
-
客户端提交的参数需要校验:即使参数来自服务端生成的选项,如下拉列表,也不能盲目信任。
-
请求头里的信息不可全信:请求头信息(如IP地址、Cookie等)可以被篡改,因此不能用作关键的业务逻辑判断依据。
-
用户标识不能从客户端获取:服务端应从会话中获取用户标识,而不是依赖客户端传递的数据。
客户端的计算不可信
举个例子: 电商下单场景涉及到客户端和服务端之间的数据安全问题。
1. 初始错误示例
Order
对象直接由客户端传递到服务端,并被用来创建订单。
@PostMapping("/order")
public void wrong(@RequestBody Order order) {this.createOrder(order);
}
问题:
- 信任客户端数据:客户端传递的
Order
对象中包含了商品的价格和总价,这些信息完全依赖客户端的计算。如果黑客篡改了这些数据,订单将使用不正确的价格,这可能会导致经济损失。
2. 改进后的正确示例
在改进后的代码中,服务端重新从数据库获取了商品信息,并且重新计算了价格,以确保订单的准确性和安全性。
@PostMapping("/orderRight")
public void right(@RequestBody Order order) {// 根据ID重新查询商品信息Item item = Db.getItem(order.getItemId());// 验证客户端传入的价格与服务端的价格是否一致if (!order.getItemPrice().equals(item.getItemPrice())) {throw new RuntimeException("您选购的商品价格有变化,请重新下单");}// 重新计算商品总价BigDecimal totalPrice = item.getItemPrice().multiply(BigDecimal.valueOf(order.getQuantity()));// 验证总价是否匹配if (order.getItemTotalPrice().compareTo(totalPrice) != 0) {throw new RuntimeException("您选购的商品总价有变化,请重新下单");}// 最终设置正确的价格信息order.setItemPrice(item.getItemPrice());order.setItemTotalPrice(totalPrice);// 创建订单createOrder(order);
}
改进点:
- 重新获取商品信息:通过
itemId
从数据库重新获取商品价格,避免信任客户端传递的价格。 - 重新计算总价:在服务端根据获取的单价和数量重新计算总价,确保客户端传递的价格没有被篡改。
- 友好提示:如果发现客户端传递的数据与服务端计算的不一致,抛出异常并返回友好的提示信息,让用户重新下单。
3. 更进一步的优化:使用简化的请求对象
为了进一步降低安全风险,可以设计一个只包含必要数据的请求对象 CreateOrderRequest
,避免将整个 Order
对象暴露给客户端。
@Data
public class CreateOrderRequest {private long itemId; // 商品IDprivate int quantity; // 商品数量
}@PostMapping("/orderRight2")
public Order right2(@RequestBody CreateOrderRequest createOrderRequest) {// 商品ID和商品数量是可信的,其他数据需要由服务端计算Item item = Db.getItem(createOrderRequest.getItemId());Order order = new Order();order.setItemPrice(item.getItemPrice());order.setItemTotalPrice(item.getItemPrice().multiply(BigDecimal.valueOf(createOrderRequest.getQuantity())));createOrder(order);return order;
}
优点:
- 减少数据传递:客户端只需要传递必要的商品ID和数量,其他信息由服务端处理,降低了数据被篡改的风险。
- 服务端控制:所有与订单价格相关的计算都在服务端进行,确保数据的完整性和安全性。
在设计与实现电商系统的订单处理时,必须明确哪些数据是客户端可信赖的,哪些数据需要在服务端重新计算。客户端可以传递商品ID和数量等基础数据,但涉及价格等敏感信息必须在服务端获取和计算,以避免因数据篡改带来的安全风险。
通过使用精简的请求对象并在服务端重新计算订单信息,不仅提高了系统的安全性,还使得代码更为清晰、职责更为分明。
客户端提交的参数需要校验
场景: 用户通过网页选择国家进行注册,页面显示了服务端支持的国家列表(中国、美国、英国)。虽然前端页面看似受控,只能选择特定国家,但黑客可以通过工具直接提交任意国家的ID,例如未被服务端显示的日本(ID为4),导致注册功能被滥用。
这个场景展示了服务端信任客户端提交的数据所带来的潜在风险,尤其是在用户注册、表单提交等常见场景中。客户端的数据来源即使看似来自于服务端,也不能完全信任,必须对客户端提交的参数进行严格校验。
初始错误示例
直接信任客户端传递的 countryId
,导致潜在安全漏洞:
@PostMapping("/wrong")
@ResponseBody
public String wrong(@RequestParam("countryId") int countryId) {return allCountries.get(countryId).getName();
}
问题:
- 不校验参数合法性:没有对客户端传递的
countryId
进行任何验证,直接使用,允许黑客通过手动请求绕过限制,选择本不该支持的国家进行注册。
改进后的正确示例
对客户端提交的 countryId
参数进行有效性校验,确保它在合法范围内。
方式一:手动校验参数
通过手动逻辑校验 countryId
是否在预期范围内:
@PostMapping("/right")
@ResponseBody
public String right(@RequestParam("countryId") int countryId) {// 检查 countryId 是否在合法范围内if (countryId < 1 || countryId > 3) {throw new RuntimeException("非法参数");}return allCountries.get(countryId).getName();
}
优点:
- 简单直接:手动校验代码直观易懂,适合简单场景。
缺点:
- 扩展性差:手动校验逻辑可能随着业务复杂度增加而变得冗长难以维护。
方式二:使用 Spring Validation 进行参数校验
通过注解方式,更加优雅地进行参数校验:
@Validated
public class TrustClientParameterController {@PostMapping("/better")@ResponseBodypublic String better(@RequestParam("countryId")@Min(value = 1, message = "非法参数")@Max(value = 3, message = "非法参数") int countryId) {return allCountries.get(countryId).getName();}
}
优点:
- 优雅简洁:通过注解实现参数校验,使得代码更加简洁,易于维护。
- 可扩展性:适合与其他Spring框架功能结合,如绑定多个校验规则。
隐藏域数据的风险
另一个常见的安全问题是将数据存储在网页的隐藏域中,然后在提交表单时返回给服务端。例如,一些服务端的中间数据在下次请求时需要重新使用,这些数据可能被放在隐藏域中。然而,这些数据同样可以被用户篡改。因此,使用隐藏域数据时也需要进行严格校验。
建议
- 永远不要信任客户端数据:无论数据看似多么可靠,都需要在服务端进行校验。
- 采用有效的校验机制:手动校验适用于简单场景,而注解方式则更适合复杂和可扩展的系统。
- 谨慎使用隐藏域:在使用隐藏域传递数据时,要注意其可能被篡改的风险,并在服务端做好校验。
不能信任请求头里的任何内容
场景: 我们有一个需求,需要防止相同用户多次领取奖品。因为未注册的用户没有唯一的用户标识,所以我们可能会根据请求的 IP 地址来判断用户是否已经领取过奖品。
初始实现
初步实现是通过 X-Forwarded-For
请求头或者 HttpServletRequest.getRemoteAddr()
获取用户的 IP 地址,然后将这个 IP 存入 HashSet
中,作为判断用户是否已经领取过奖品的依据。
@Slf4j
@RequestMapping("trustclientip")
@RestController
public class TrustClientIpController {HashSet<String> activityLimit = new HashSet<>();@GetMapping("test")public String test(HttpServletRequest request) {String ip = getClientIp(request);if (activityLimit.contains(ip)) {return "您已经领取过奖品";} else {activityLimit.add(ip);return "奖品领取成功";}}private String getClientIp(HttpServletRequest request) {String xff = request.getHeader("X-Forwarded-For");if (xff == null) {return request.getRemoteAddr();} else {return xff.contains(",") ? xff.split(",")[0] : xff;}}
}
存在问题
-
请求头容易被篡改:
X-Forwarded-For
是一个可以被客户端随意修改的请求头,黑客可以通过工具模拟请求并伪造不同的 IP 地址,从而绕过限制,反复领取奖品。 -
IP 地址共享:在公共场所(如网吧、学校)中,所有用户可能共享相同的出口 IP,这会导致第一个用户领取奖品后,其他用户无法领取。
-
不可靠的唯一性标识:IP 地址本身并不是可靠的唯一标识,因为它可能是动态分配的,多个用户可能共享同一个 IP,或者同一用户的 IP 地址可能发生变化。
改进建议
1. 用户登录或三方授权登录
- 最可靠的方式是要求用户登录或者使用三方授权(如微信登录),通过获取用户的唯一标识(如用户ID、微信OpenID)来进行唯一性判断。这样可以确保每个用户只能领取一次奖品。
2. 使用其他多重校验机制
- 可以结合多个因素进行校验,例如 IP 地址、设备指纹、用户代理信息等,构建一个综合的唯一性判断机制。虽然单独依赖这些信息不可靠,但组合使用可以提高防刷的效果。
3. 基于 Token 的防刷机制
- 使用短期有效的 Token 来限制短时间内的重复请求。每次请求后生成一个 Token,Token 的有效性可以和 IP、用户标识等结合,这样即使用户换了 IP 地址或者篡改了请求头,也不能绕过防刷机制。
改进后的实现示例
使用登录标识进行唯一性判断:
@RestController
@RequestMapping("secure")
public class SecureRewardController {private final Set<String> rewardedUsers = new HashSet<>();@GetMapping("/getReward")public String getReward(@RequestParam("userId") String userId) {if (rewardedUsers.contains(userId)) {return "您已经领取过奖品";} else {rewardedUsers.add(userId);return "奖品领取成功";}}
}
优点:
- 用户通过登录获取唯一标识,确保防刷机制的可靠性。
- 不再依赖请求头或 IP 地址,这些信息只能作为参考而非判断依据。
建议
-
不要信任请求头中的信息:尤其是
X-Forwarded-For
这样的头部信息,它可以被轻易篡改,不能作为判断用户身份的依据。 -
避免依赖 IP 地址:IP 地址既不唯一,也不可靠,公共场所的用户可能共享 IP,导致错误的判断结果。
-
用户登录是更好的选择:要求用户登录或使用三方授权登录,获取唯一标识来进行重要逻辑判断。
-
考虑多重校验:在无法要求用户登录的情况下,结合多个参数进行防刷校验,如设备指纹、用户代理等。
用户标识不能从客户端获取
场景: 在处理用户登录和身份验证时,直接使用客户端传来的用户ID
@GetMapping("wrong")public String wrong(@RequestParam("userId") Long userId) {return "当前用户Id:" + userId;
}
问题描述
如果服务端直接使用客户端传来的用户ID来进行身份验证或授权,这可能会导致如下问题:
- 身份冒充:恶意用户可以伪造或篡改用户ID,从而冒充其他用户访问他们的资源。
- 越权访问:用户可以通过修改用户ID来尝试访问本不属于他们的资源。
- 系统漏洞利用:攻击者可以通过篡改用户ID来测试系统的漏洞,尝试越权获取敏感信息。
安全的用户身份验证方案
为了避免上述问题,建议采用以下方案:
-
服务器端管理用户会话:用户登录后,服务器端应生成会话信息并在Session或其他安全存储机制中保存用户标识。所有后续请求应通过会话获取用户ID,而不是依赖客户端传来的用户ID。
-
使用自定义注解和参数解析器:可以使用Spring的自定义注解和参数解析器,通过这种方式确保在需要用户标识的地方,自动从Session中获取用户ID,而不是直接使用客户端传来的参数。
代码实现示例
方案一: 用户登录
用户登录后,将用户ID存入Session中:
@GetMapping("login")
public long login(@RequestParam("username") String username, @RequestParam("password") String password, HttpSession session) {if ("admin".equals(username) && "admin".equals(password)) {session.setAttribute("currentUser", 1L);return 1L;}return 0L;
}
方案二: 开发小技巧
如果希望每一个需要登录的方法,都从 Session 中获得当前用户标识,并进行一些后续处理的话,我们没有必要在每一个方法内都复制粘贴相同的获取用户身份的逻辑,可以定义一个自定义注解 @LoginRequired
到 userId
参数上,然后通过HandlerMethodArgumentResolver
自动实现参数的组装
自定义注解 @LoginRequired
创建一个自定义注解 @LoginRequired
用于标记需要自动注入用户ID的参数:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@Documented
public @interface LoginRequired {String sessionKey() default "currentUser";
}
自定义参数解析器 LoginRequiredArgumentResolver
实现 HandlerMethodArgumentResolver
接口,确保在每次调用需要用户ID的Controller方法时,自动从Session中获取用户ID:
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;@Slf4j
public class LoginRequiredArgumentResolver implements HandlerMethodArgumentResolver {@Overridepublic boolean supportsParameter(MethodParameter methodParameter) {return methodParameter.hasParameterAnnotation(LoginRequired.class);}@Overridepublic Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {LoginRequired loginRequired = methodParameter.getParameterAnnotation(LoginRequired.class);Object object = nativeWebRequest.getAttribute(loginRequired.sessionKey(), NativeWebRequest.SCOPE_SESSION);if (object == null) {log.error("接口 {} 非法调用!", methodParameter.getMethod().toString());throw new RuntimeException("请先登录!");}return object;}
}
将参数解析器添加到Spring上下文中
通过实现 WebMvcConfigurer
接口,将自定义的 LoginRequiredArgumentResolver
注册到Spring中:
@SpringBootApplication
public class CommonMistakesApplication implements WebMvcConfigurer {@Overridepublic void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {resolvers.add(new LoginRequiredArgumentResolver());}
}
使用 @LoginRequired
注解的Controller方法
现在,在需要用户身份的Controller方法中,只需使用 @LoginRequired
注解,用户ID将自动从Session中获取:
@GetMapping("right")
public String right(@LoginRequired Long userId) {return "当前用户Id:" + userId;
}
建议
-
不要信任客户端传来的用户ID:客户端的数据可以轻易被篡改,使用客户端传来的用户ID进行身份验证是非常危险的做法。
-
会话管理:应通过服务器端的会话管理来确保用户的身份验证,所有与用户相关的操作都应基于从Session中获取的用户ID。
-
使用Spring的自定义注解和参数解析器:通过这种方式,可以简化代码,同时确保安全性,提高开发效率。
总结
今天一起梳理了任何客户端的东西都不可信任”这一重要的安全原则,并列举了几个典型的错误和解决方案:
-
客户端的计算不可信:
- 虽然前端技术越来越强大,可以进行大量的逻辑计算,但这些计算结果不能直接用于关键的业务决策。服务端应始终重新计算和验证关键数据(如价格),确保业务逻辑的完整性。
-
所有客户端传递的参数都需要校验:
- 即便参数来自受控的界面元素(如下拉列表),也必须在服务端进行校验。这样可以防止攻击者绕过UI通过工具直接向服务端提交非法数据。
-
请求头中的信息不能信任:
- 请求头(如IP地址、Referer、Cookie)可以被篡改,因此不能依赖这些信息进行关键决策。它们只能用作参考或日志记录,而非业务逻辑的核心部分。
-
外部接口禁止直接使用客户端提供的用户标识:
- 用户标识应由服务端管理,通过身份验证后在会话或Token中保存。对于面向外部用户的接口,用户标识必须从服务端获取,避免潜在的安全风险。对于内部接口,也要确保其仅限内部使用,且要考虑调用方的授权。
安全问题是系统的木桶效应,最薄弱的环节决定了整体的安全性。开发者需要具备基本的安全意识,避免常见的低级安全问题,从源头上保障系统的安全。