客户端的计算不可信:即使客户端计算了数据,服务端也必须重新计算并校验,以防止恶意篡改数据。
客户端提交的参数需要校验:即使参数来自服务端生成的选项,如下拉列表,也不能盲目信任。
请求头里的信息不可全信:请求头信息(如IP地址、Cookie等)可以被篡改,因此不能用作关键的业务逻辑判断依据。
用户标识不能从客户端获取:服务端应从会话中获取用户标识,而不是依赖客户端传递的数据。
举个例子: 电商下单场景涉及到客户端和服务端之间的数据安全问题。
Order
对象直接由客户端传递到服务端,并被用来创建订单。
@PostMapping("/order")
public void wrong(@RequestBody Order order) {
this.createOrder(order);
}
问题:
Order
对象中包含了商品的价格和总价,这些信息完全依赖客户端的计算。如果黑客篡改了这些数据,订单将使用不正确的价格,这可能会导致经济损失。在改进后的代码中,服务端重新从数据库获取了商品信息,并且重新计算了价格,以确保订单的准确性和安全性。
@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
从数据库重新获取商品价格,避免信任客户端传递的价格。为了进一步降低安全风险,可以设计一个只包含必要数据的请求对象 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为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();
}
优点:
缺点:
通过注解方式,更加优雅地进行参数校验:
@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();
}
}
优点:
另一个常见的安全问题是将数据存储在网页的隐藏域中,然后在提交表单时返回给服务端。例如,一些服务端的中间数据在下次请求时需要重新使用,这些数据可能被放在隐藏域中。然而,这些数据同样可以被用户篡改。因此,使用隐藏域数据时也需要进行严格校验。
场景: 我们有一个需求,需要防止相同用户多次领取奖品。因为未注册的用户没有唯一的用户标识,所以我们可能会根据请求的 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. 用户登录或三方授权登录
2. 使用其他多重校验机制
3. 基于 Token 的防刷机制
使用登录标识进行唯一性判断:
@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 "奖品领取成功";
}
}
}
优点:
不要信任请求头中的信息:尤其是 X-Forwarded-For
这样的头部信息,它可以被轻易篡改,不能作为判断用户身份的依据。
避免依赖 IP 地址:IP 地址既不唯一,也不可靠,公共场所的用户可能共享 IP,导致错误的判断结果。
用户登录是更好的选择:要求用户登录或使用三方授权登录,获取唯一标识来进行重要逻辑判断。
考虑多重校验:在无法要求用户登录的情况下,结合多个参数进行防刷校验,如设备指纹、用户代理等。
场景: 在处理用户登录和身份验证时,直接使用客户端传来的用户ID
@GetMapping("wrong")
public String wrong(@RequestParam("userId") Long userId) {
return "当前用户Id:" + userId;
}
如果服务端直接使用客户端传来的用户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;
}
}
通过实现 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的自定义注解和参数解析器:通过这种方式,可以简化代码,同时确保安全性,提高开发效率。
今天一起梳理了任何客户端的东西都不可信任”这一重要的安全原则,并列举了几个典型的错误和解决方案:
客户端的计算不可信:
所有客户端传递的参数都需要校验:
请求头中的信息不能信任:
外部接口禁止直接使用客户端提供的用户标识:
安全问题是系统的木桶效应,最薄弱的环节决定了整体的安全性。开发者需要具备基本的安全意识,避免常见的低级安全问题,从源头上保障系统的安全。