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; // 商品ID
- private 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")
- @ResponseBody
- public 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 {
- @Override
- public boolean supportsParameter(MethodParameter methodParameter) {
- return methodParameter.hasParameterAnnotation(LoginRequired.class);
- }
- @Override
- public 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 {
- @Override
- public 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中保存。对于面向外部用户的接口,用户标识必须从服务端获取,制止潜在的安全风险。对于内部接口,也要确保其仅限内部利用,且要考虑调用方的授权。
安全问题是体系的木桶效应,最薄弱的环节决定了整体的安全性。开发者需要具备基本的安全意识,制止常见的低级安全问题,从源头上保障体系的安全。
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。 |