任何客户端的东西都是不可信的

本文摘录总结于极客时间——《Java 业务开发常见错误 100 例》

  对于 HTTP 请求,我们要在脑子里有一个根深蒂固的概念,那就是任何客户端传过来的数据都是不能直接信任的。客户端传给服务端的数据只是信息收集,数据需要经过有效性验证、权限验证等后才能使用,并且认为这些数据只是用户操作的意图,而不是代表当前数据的状态。
  举一个简单的例子,比如我们玩游戏的时候移动了多少个位置,客户端发给服务端的只是用户的操作,由服务端根据用户当前的状态来设置新的位置再返回给客户端。为了防止作弊,不可能由客户端直接告诉服务端用户当前的位置。因此有时候网络状态不太好的情况下,我们往往会遇到走了几步又“瞬移”回来的现象。

客户端的计算不可信

  我们先看一个电商下单操作的案例。在这个场景下,可能会暴露这么一个 /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);
}

  还有一种方法是让客户端仅传入需要的数据给服务端,像这样重新定义一个 POOJO 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<Integer, Country> 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<Country> countries = new ArrayList<>();
        //从数据库查出ID<4的三个国家作为白名单在页面显示
        countries.addAll(allCountries.values().stream().filter(country -> country.getId()<4).collect(Collectors.toList()));
        modelMap.addAttribute("countries", countries);
        return "index";
    }
} 

  再通过服务端返回的数据进行渲染:

...
<form id="myForm" method="post" th:action="@{/trustclientdata/wrong}">


    <select id="countryId" name="countryId">
        <option value="0">Select country</option>
        <option th:each="country : ${countries}" th:text="${country.name}" th:value="${country.id}"></option>
    </select>


    <button th:text="Register" type="submit"/>
</form>
...

  在页面上只有三个国家的可选项:

  但我们要知道的是,页面是给普通用户使用的,而黑客不会在乎页面显示什么,完全有可能尝试给服务端返回页面上没显示的其他国家 ID。如果像这样直接信任客户端传来的国家 ID 的话,很可能会把用户注册功能开放给其他国家的人:

@PostMapping("/wrong")
@ResponseBody
public String wrong(@RequestParam("countryId") int countryId) {
    return allCountries.get(countryId).getName();
}

  即使我们知道参数的范围来自下拉框,而下拉框的内容也来自服务端,也需要对参数进行校验。因为接口不一定要通过浏览器请求,只要知道接口定义完全可以通过其他工具提交:

curl http://localhost:45678/trustclientdata/wrong\?countryId=4 -X POST

  修改方式是,在使用客户端传过来的参数之前,对参数进行有效性校验:

@PostMapping("/right")
@ResponseBody
public String right(@RequestParam("countryId") int countryId) {
    if (countryId < 1 || countryId > 3)
        throw new RuntimeException("非法参数");
    return allCountries.get(countryId).getName();
}

// 又或者使用 spring 注解的形式
@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();
    }
}

不能信任请求头里的任何内容

  上面提到了 HTTP 请求传过来的数据不可信,另外,请求头内的内容也不可信!
  一个比较常见的需求是,为了防刷,我们需要判断用户的唯一性。比如,针对未注册的新用户发送一些小奖品,我们不希望相同用户多次获得奖品。考虑到未注册的用户因为没有登录过所以没有用户标识,我们可能会想到根据请求的 IP 地址,来判断用户是否已经领过奖品。
  比如下面这段测试代码,我们通过一个 HashSet 模拟已发放过奖品的 IP 地址,每次领奖后就把 IP 地址加入这个名单中。IP 地址的获取方式是:优先通过 X-Forwarded-For 请求头来获取,如果没有的话再通过 HttpServletRequest 的 getRemoteAddr 方法来获取。

@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 请求头来判断用户唯一性的实现方式,是有问题的:

  • 完全可以通过 cURL 类似的工具来模拟请求,随意篡改头的内容:
curl http://localhost:45678/trustclientip/test -H "X-Forwarded-For:183.84.18.71, 10.253.15.1"
  • 网吧、学校等机构的出口 IP 往往是同一个,在这个场景下,可能只有最先打开这个页面的用户才能领取到奖品,而其他用户会被阻拦。

  因此!IP 地址或者说请求头里的任何信息,包括 Cookie 中的信息、Referer,只能用作参考,不能用作重要逻辑判断的依据。而对于类似这个案例唯一性的判断需求,更好的做法是,让用户进行登录或三方授权登录(比如微信),拿到用户标识来做唯一性判断。

用户标识不能从客户端获取

  聊到用户登录,业务代码非常容易犯错的一个地方是,使用了客户端传给服务端的用户 ID,类似这样:

@GetMapping("wrong")
public String wrong(@RequestParam("userId") Long userId) {
    return "当前用户Id:" + userId;
}

  这种是会直接导致安全问题的。犯类似低级错误的原因,有三个:

  1. 开发同学没有正确认识接口或服务面向的用户。如果接口面向内部服务,由服务调用方传入用户 ID 没什么不合理,但是这样的接口不能直接开放给客户端或 H5 使用。
  2. 在测试阶段为了方便测试调试,我们通常会实现一些无需登录即可使用的接口,直接使用客户端传过来的用户标识,却在上线之前忘记删除类似的超级接口。
  3. 一个大型网站前端可能由不同的模块构成,不一定是一个系统,而用户登录状态可能也没有打通。有些时候,我们图简单可能会在 URL 中直接传用户 ID,以实现通过前端传值来打通用户登录状态。

  如果你的接口直面用户(比如给客户端或 H5 页面调用),那么一定需要用户先登录才能使用。

Spring Web 小技巧

  如果希望每一个需要登录的方法,都从 Session 中获得当前用户标识,并进行一些后续处理的话,我们没有必要在每一个方法内都复制粘贴相同的获取用户身份的逻辑,可以定义一个自定义注解 @LoginRequired 到 userId 参数上,然后通过 HandlerMethodArgumentResolver 自动实现参数的组装:

@GetMapping("right")
public String right(@LoginRequired Long userId) {
    return "当前用户Id:" + userId;
}

  @LoginRequired 本身并无特殊,只是一个自定义注解:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@Documented
public @interface LoginRequired {
    String sessionKey() default "currentUser";
}

  魔法来自 HandlerMethodArgumentResolver。我们自定义了一个实现类 LoginRequiredArgumentResolver,实现了 HandlerMethodArgumentResolver 接口的 2 个方法:

  • supportsParameter 方法判断当参数上有 @LoginRequired 注解时,再做自定义参数解析的处理;
  • resolveArgument 方法用来实现解析逻辑本身。在这里,我们尝试从 Session 中获取当前用户的标识,如果无法获取到的话提示非法调用的错误,如果获取到则返回 userId。这样一来,Controller 中的 userId 参数就可以自动赋值了。
@Slf4j
public class LoginRequiredArgumentResolver implements HandlerMethodArgumentResolver {
    //解析哪些参数
    @Override
    public boolean supportsParameter(MethodParameter methodParameter) {
        //匹配参数上具有@LoginRequired注解的参数
        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);
        //根据注解中的Session Key,从Session中查询用户信息
        Object object = nativeWebRequest.getAttribute(loginRequired.sessionKey(), NativeWebRequest.SCOPE_SESSION);
        if (object == null) {
            log.error("接口 {} 非法调用!", methodParameter.getMethod().toString());
            throw new RuntimeException("请先登录!");
        }
        return object;
    }
}

  当然,我们要实现 WebMvcConfigurer 接口的 addArgumentResolvers 方法,来增加这个自定义的处理器 LoginRequiredArgumentResolver:

SpringBootApplication
public class CommonMistakesApplication implements WebMvcConfigurer {
...
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginRequiredArgumentResolver());
    }
}

  测试发现,经过这样的实现,登录后所有需要登录的方法都可以一键通过加 @LoginRequired 注解来拿到用户标识,方便且安全:

posted @   小林也要龙女仆  阅读(206)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 如何调用 DeepSeek 的自然语言处理 API 接口并集成到在线客服系统
· 【译】Visual Studio 中新的强大生产力特性
· 2025年我用 Compose 写了一个 Todo App
点击右上角即可分享
微信分享提示