# 27 | 数据源头:任何客户端的东西都不可信任 你好,我是朱晔。 从今天开始,我要和你讨论几个有关安全的话题。首先声明,我不是安全专家,但我发现有这么一个问题,那就是许多做业务开发的同学往往一点点安全意识都没有。如果有些公司没有安全部门或专家的话,安全问题就会非常严重。 如果只是用一些所谓的渗透服务浅层次地做一下扫描和渗透,而不在代码和逻辑层面做进一步分析的话,能够发现的安全问题非常有限。要做好安全,还是要靠一线程序员和产品经理点点滴滴的意识。 所以接下来的几篇文章,我会从业务开发的角度,和你说说我们应该最应该具备的安全意识。 对于HTTP请求,我们要在脑子里有一个根深蒂固的概念,那就是**任何客户端传过来的数据都是不能直接信任的**。客户端传给服务端的数据只是信息收集,数据需要经过有效性验证、权限验证等后才能使用,并且这些数据只能认为是用户操作的意图,不能直接代表数据当前的状态。 举一个简单的例子,我们打游戏的时候,客户端发给服务端的只是用户的操作,比如移动了多少位置,由服务端根据用户当前的状态来设置新的位置再返回给客户端。为了防止作弊,不可能由客户端直接告诉服务端用户当前的位置。 因此,客户端发给服务端的指令,代表的只是操作指令,并不能直接决定用户的状态,对于状态改变的计算在服务端。而网络不好时,我们往往会遇到走了10步又被服务端拉回来的现象,就是因为有指令丢失,客户端使用服务端计算的实际位置修正了客户端玩家的位置。 今天,我通过四个案例来和你说说,为什么“任何客户端的东西都不可信任”。 ## 客户端的计算不可信 我们先看一个电商下单操作的案例。 在这个场景下,可能会暴露这么一个/order的POST接口给客户端,让客户端直接把组装后的订单信息Order传给服务端: ``` @PostMapping("/order") public void wrong(@RequestBody Order order) { this.createOrder(order); } ``` 订单信息Order可能包括商品ID、商品价格、数量、商品总价: ``` @Data public class Order { private long itemId; //商品ID private BigDecimal itemPrice; //商品价格 private int quantity; //商品数量 private BigDecimal itemTotalPrice; //商品总价 } ``` 虽然用户下单时客户端肯定有商品的价格等信息,也会计算出订单的总价给用户确认,但是这些信息只能用于呈现和核对。即使客户端传给服务端的POJO中包含了这些信息,服务端也一定要重新从数据库来初始化商品的价格,重新计算最终的订单价格。**如果不这么做的话,很可能会被黑客利用,商品总价被恶意修改为比较低的价格。** 因此,我们真正直接使用的、可信赖的只是客户端传过来的商品ID和数量,服务端会根据这些信息重新计算最终的总价。如果服务端计算出来的商品价格和客户端传过来的价格不匹配的话,可以给客户端友好提示,让用户重新下单。修改后的代码如下: ``` @PostMapping("/orderRight") public void right(@RequestBody Order order) { //根据ID重新查询商品 Item item = Db.getItem(order.getItemId()); //客户端传入的和服务端查询到的商品单价不匹配的时候,给予友好提示 if (!order.getItemPrice().equals(item.getItemPrice())) { throw new RuntimeException("您选购的商品价格有变化,请重新下单"); } //重新设置商品单价 order.setItemPrice(item.getItemPrice()); //重新计算商品总价 BigDecimal totalPrice = item.getItemPrice().multiply(BigDecimal.valueOf(order.getQuantity())); //客户端传入的和服务端查询到的商品总价不匹配的时候,给予友好提示 if (order.getItemTotalPrice().compareTo(totalPrice)!=0) { throw new RuntimeException("您选购的商品总价有变化,请重新下单"); } //重新设置商品总价 order.setItemTotalPrice(totalPrice); createOrder(order); } ``` 还有一种可行的做法是,让客户端仅传入需要的数据给服务端,像这样重新定义一个POJO 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(order.getQuantity()))); createOrder(order); return order; } ``` 通过这个案例我们可以看到,在处理客户端提交过来的数据时,服务端需要明确区分,哪些数据是需要客户端提供的,哪些数据是客户端从服务端获取后在客户端计算的。其中,前者可以信任;而后者不可信任,服务端需要重新计算,如果客户端和服务端计算结果不一致的话,可以给予友好提示。 ## 客户端提交的参数需要校验 对于客户端的数据,我们还容易忽略的一点是,**误以为客户端的数据来源是服务端,客户端就不可能提交异常数据**。我们看一个案例。 有一个用户注册页面要让用户选择所在国家,我们会把服务端支持的国家列表返回给页面,供用户选择。如下代码所示,我们的注册只支持中国、美国和英国三个国家,并不对其他国家开放,因此从数据库中筛选了id<4的国家返回给页面进行填充: ``` @Slf4j @RequestMapping("trustclientdata") @Controller public class TrustClientDataController { //所有支持的国家 private HashMap allCountries = new HashMap<>(); public TrustClientDataController() { allCountries.put(1, new Country(1, "China")); allCountries.put(2, new Country(2, "US")); allCountries.put(3, new Country(3, "UK")); allCountries.put(4, new Country(4, "Japan")); } @GetMapping("/") public String index(ModelMap modelMap) { List countries = new ArrayList<>(); //从数据库查出ID<4的三个国家作为白名单在页面显示 countries.addAll(allCountries.values().stream().filter(country -> country.getId()<4).collect(Collectors.toList())); modelMap.addAttribute("countries", countries); return "index"; } } ``` 我们通过服务端返回的数据来渲染模板: ``` ...