一、客户端的计算不可信
1、服务端计算价格,如果不这么做的话,很可能会被黑客利用,商品总价被恶意修改为比较低的价格。
二、客户端提交的参数需要校验
1、误以为客户端的数据来源是服务端,客户端就不可能提交异常数据
2、对参数进行有效性校验:
@PostMapping("/right")
@ResponseBody
public String right(@RequestParam("countryId") int countryId) {
if (countryId < 1 || countryId > 3)
throw new RuntimeException("非法参数");
return allCountries.get(countryId).getName();
}
3、不能信任请求头里的任何内容
① 这种过于依赖 X-Forwarded-For 请求头来判断用户唯一性的实现方式,是有问题的:
② 更好的做法是,让用户进行登录或三方授权登录(比如微信),拿到用户标识来做唯一性判断。
4、用户标识不能从客户端获取
① 一个大项目因为服务端直接使用了客户端传过来的用户标识,导致了安全问题。
a、开发同学没有正确认识接口或服务面向的用户。如果接口面向内部服务,由服务调用方传入用户 ID 没什么不合理,但是这样的接口不能直接开放给客户端或 H5 使用。
b、在测试阶段为了方便测试调试,我们通常会实现一些无需登录即可使用的接口,直接使用客户端传过来的用户标识,却在上线之前忘记删除类似的超级接口。
c、一个大型网站前端可能由不同的模块构成,不一定是一个系统,而用户登录状态可能也没有打通。有些时候,我们图简单可能会在 URL 中直接传用户 ID,以实现通过前端传值来打通用户登录状态。
② 如果希望每一个需要登录的方法,都从 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());
}
}
三、开放平台资源的使用需要考虑防刷
1、只有固定的请求头才能发送验证码:
比如,判断是否存在浏览器或手机型号、设备分辨率请求头。对于那些使用爬虫来抓取短信接口地址的程序来说,往往只能抓取到 URL,而难以分析出请求发送短信还需要的额外请求头,可以看作第一道基本防御。
2、只有先到过注册页面才能发送验证码:
我们可以在页面或界面打开时请求固定的前置接口,为这个设备开启允许发送验证码的窗口,之后的请求发送验证码才是有效请求。
这种方式可以防御直接绕开固定流程,通过接口直接调用的发送验证码请求,并不会干扰普通用户。
3、控制相同手机号的发送次数和发送频次
除非是短信无法收到,否则用户不太会请求了验证码后不完成注册流程,再重新请求。因此,我们可以限制同一手机号每天的最大请求次数。验证码的到达需要时间,太短的发送间隔没有意义,所以我们还可以控制发送的最短间隔。比如,我们可以控制相同手机号一天只能发送 10 次验证码,最短发送间隔 1 分钟。
4、增加前置图形验证码
短信轰炸平台一般会收集很多免费短信接口,一个接口只会给一个用户发一次短信,所以控制相同手机号发送次数和间隔的方式不够有效。这时,我们可以考虑对用户体验稍微有影响,但也是最有效的方式作为保底,即将弹出图形验证码作为前置。
只有正常用户经过正常的流程才能使用开放平台资源,并且资源的用量在业务需求合理范围内。此外,还需要考虑做好短信发送量的实时监控,遇到发送量激增要及时报警。
四、虚拟资产并不能凭空产生无限使用
1、更合适的做法是,把优惠券看作一种资源,其生产不是凭空的,而是需要事先申请
a、虚拟资产如果最终可以对应到真实金钱上的优惠,那么,能发多少取决于运营和财务的核算,应该是有计划、有上限的。
b、即使虚拟资产不值钱,大量不合常规的虚拟资产流入市场,也会冲垮虚拟资产的经济体系,造成虚拟货币的极速贬值。有量的控制才有价值。
c、资产的申请需要理由,甚至需要走流程,这样才可以追溯是什么活动需要、谁提出的申请,程序依据申请批次来发放。
五、钱的进出一定要和订单挂钩并且实现幂等
1、任何资金操作都需要在平台侧生成业务属性的订单,可以是优惠券发放订单,可以是返现订单,也可以是借款订单,一定是先有订单再去做资金操作。同时,订单的产生需要有业务属性。业务属性是指,订单不是凭空产生的,否则就没有控制的意义。比如,返现发放订单必须关联到原先的商品订单产生;再比如,借款订单必须关联到同一个借款合同产生。
2、一定要做好防重,也就是实现幂等处理,并且幂等处理必须是全链路的。这里的全链路是指,从前到后都需要有相同的业务订单号来贯穿,实现最终的支付防重。
//错误:每次使用UUID作为订单号
@GetMapping("wrong")
public void wrong(@RequestParam("orderId") String orderId) {
PayChannel.pay(UUID.randomUUID().toString(), "123", new BigDecimal("100"));
}
//正确:使用相同的业务订单号
@GetMapping("right")
public void right(@RequestParam("orderId") String orderId) {
PayChannel.pay(orderId, "123", new BigDecimal("100"));
}
//三方支付通道
public class PayChannel {
public static void pay(String orderId, String account, BigDecimal amount) {
...
}
}
对于相同的商户订单号,无法进行重复的资金处理,所以三方公司的接口可以实现唯一订单号的幂等处理。
六、XSS防御
第一,要从根本上、从最底层进行堵漏,尽量不要在高层框架层面做,否则堵漏可能不彻底。
第二,堵漏要同时考虑进和出,不仅要确保数据存入数据库的时候进行了转义或过滤,还要在取出数据呈现的时候再次转义,确保万无一失。
第三,除了直接堵漏外,我们还可以通过一些额外的手段限制漏洞的威力。比如,为 Cookie 设置 HttpOnly 属性,来防止数据被脚本读取;又比如,尽可能限制字段的最大保存长度,即使出现漏洞,也会因为长度问题限制黑客构造复杂攻击脚本的能力。
七、正确保存和传输敏感数据
1、用户密码不能加密保存,更不能明文保存,需要使用全球唯一的、具有一定长度的、随机的盐,配合单向散列算法保存。使用 BCrypt 算法,是一个比较好的实践。
2、诸如姓名和身份证这种需要可逆解密查询的敏感信息,需要使用对称加密算法保存。我的建议是,把脱敏数据和密文保存在业务数据库,独立使用加密服务来做数据加解密;对称加密需要用到的密钥和初始化向量,可以和业务数据库分开保存。
八、https原理
1、客户端告知服务端自己支持的密码套件(比如 TLS_RSA_WITH_AES_256_GCM_SHA384,其中 RSA 是密钥交换的方式,AES_256_GCM 是加密算法,SHA384 是消息验证摘要算法),提供客户端随机数。
2、服务端应答选择的密码套件,提供服务端随机数。
3、服务端发送 CA 证书给客户端,客户端验证 CA 证书(后面详细说明)。
4、客户端生成 PreMasterKey,并使用非对称加密 + 公钥加密 PreMasterKey。
5、客户端把加密后的 PreMasterKey 传给服务端。
6、服务端使用非对称加密 + 私钥解密得到 PreMasterKey,并使用 PreMasterKey+ 两个随机数,生成 MasterKey。
7、客户端也使用 PreMasterKey+ 两个随机数生成 MasterKey。
8、客户端告知服务端之后将进行加密传输。
9、客户端使用 MasterKey 配合对称加密算法,进行对称加密测试。
10、服务端也使用 MasterKey 配合对称加密算法,进行对称加密测试。
九、CA证书流程
1、从服务端拿到的 CA 证书是用户证书,我们需要通过证书中的签发人信息找到上级中间证书,再网上找到根证书。
2、根证书只有为数不多的权威机构才能生成,一般预置在 OS 中,根本无法伪造。找到根证书后,提取其公钥来验证中间证书的签名,判断其权威性。
3、最后再拿到中间证书的公钥,验证用户证书的签名。