接口防重放

2023-03-28, 星期二, 15:13

DevCyber Security

在一个 RESTful 服务中,幂等操作意味着客户端可以重复进行相同的调用,而不会改变系统的状态,因此总是获得相同的结果(如果在请求之间没有发生其他事件的话)。

查询操作(通常是 GETHEADOPTIONSTRACE)只是获取资源和信息,不会对系统的状态产生影响,可以认为是幂等的。

修改和删除操作(通常是 PUTDELETE)一般被认为是幂等的。需要注意在一些系统中,首次删除资源会返回 HTTP 200,之后的调用尝试将会返回 HTTP 204(No Content)HTTP 404(Not Found)。尽管响应不同,系统的状态并没有发生改变。

创建资源(通常是 POST)修改了系统的状态,因此不是幂等的。

重复调用非幂等接口可能会造成一些预料外的问题,例如重复添加资源产生了垃圾数据,或者启动了过多的任务空耗资源。

一般来说,重复调用可能来自恶意用户的重放攻击或是合法用户的重复提交。

前端防止合法用户的重复提交

一般来说,表单页面的提交按钮在响应事件中会首先禁用自身,或是立即启用一个遮罩层阻断操作。

但有时候经验不足的开发者可能没有意识到这里的交互需要做处理,又有可能是我们俗称的“卡了”导致应对机制还没来得及执行。

可以用防抖(debounce)和节流(throttle):

  • 防抖:触发事件后在指定的时间窗口内没有新的事件触发,则执行对应的事件处理器;
  • 节流:触发事件后在指定的时间窗口内只执行一次响应;

对于 JavaScript 这样函数特性非常棒的语言,有很多第三方包可以为给定方法添加节流功能,得到供其他组件调用的新函数。

// 这个方法可能会意外触发多次
function doPost(...args) { ... }

// 这个方法 3 秒钟内只会响应一次,可以添加到按钮的点击事件响应方法中
function throttledDoPost = throttle(fn = doPost, windowSizeInSeconds = 3);

后端防止恶意用户的请求重放攻击

在请求中添加时间戳和 Nonce,然后使用 HTTPS

我们可以参考 Twitter API 中的 OAuth 1.0a User Context Authentication methods

oauth_timestamp: 1318622958
oauth_nonce: kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg
oauth_signature: tnnArxj06cWHq44gCs1OSKk/jLY=

客户端需要在请求中通过 oauth_timestamp 提交当前时间戳,服务端在收到请求时比对系统时钟,超过一定时间的视为非法请求。

现实世界中我们无法保证客户端和服务端的时间一致,又或者请求需要经过层层转发,又或者是其他什么原因导致系统延时不稳定,就需要增大超时时间。如果我们认为 5 秒的延迟是可接受的,那么这 5 秒内的重放都将被视为合法请求。

于是我们引入 nonce,词源上大概是 number used once 的意思,不过给出一个无碰撞性足够好的随机字符串就行。

nonce 一般分为服务器 nonce 和客户端 cnonce,在一个典型的身份验证流程中大概是这么用的:

sequenceDiagram participant Client participant Server Client->>Server: getNonce() Server--)Client: nonce Client->>Server: login(username, cnonce, hash(nonce + cnonce + password)) Server--)Client: token

Twitter API 中的 oauth_nonce 使用的就是 cnonce。如果服务端在处理请求时发现这个 nonce 短期内出现过,就认定这是个重放请求。

如果使用了 HTTPS,那么攻击者只能重放这个包,足够解决问题了。

如果攻击者通过伪造 SSL 证书(或者两个系统使用 HTTP 直接通信)实施了中间人攻击,就需要增加一个签名 oauth_signature,把 oauth_timestampoauth_nonce 纳入到计算过程中去。

关于签名的规范各家有自己的规定,也可以参考 Twitter API 的 Creating a signature

限流算法

限流,即在一定时间区间对请求数量进行限制。主要有几个概念:

  • 阈值:单位时间内允许的请求数量,或称 QPS;
  • 拒绝策略:拒接请求或挂起;

常见算法与 Java Packages

固定窗口/计数器:单位时间周期内记录请求数量,超过阈值则拒绝接下来的请求。下个时间周期清零计数器;相应的还有滑动窗口计数器。

令牌桶(token-bucket):以一定速率向固定容量的桶中放入令牌,客户端请求需先从桶中取出令牌,如果无令牌可分配则挂起或拒绝请求。可以实现在固定时间区间内最多处理 N 个请求。

可以参考:

注意事项

需要注意的是,如果一位用户熟悉各种调试工具,能够去除前端页面上的种种限制,签发新的 nonce,使用新的时间戳签名,“正常”地重复触发表单提交流程,或是可以使用 Postman 等工具完整的构造请求,以上种种措施就失效了。

如果你还需要维持对其的正常服务,就需要业务层面的去重了。这样的问题需要具体分析,例如找出业务字段(及其组合)中的唯一索引,用来生成 Rate Limiter 的 key,或是加上锁什么的。

如果不需要的话,简单粗暴地禁止同一用户在一个时间段内再次调用某个 endpoint 也是可以的。大部分情况下在网络基础设施层面就可以做到。

实现一个玩具级的 Filter 拦截重放请求

免责声明:如果你需要在一个严肃的系统中实现访问速率控制,应该考虑 taptap/ratelimiter-spring-boot-starter 这种成熟的、自定义项丰富的、支持分布式的产品。

不过这种东西无论是原理还是实现起来其实都并不复杂。因此本文依托 Spring Boot,试图通过实现一个玩具级的 OniFilter 说明如何检查用户请求中的特定请求头,拒绝重复提交,以及如何通过 application.properties 和注解实现一定程度的自定义能力。

实现 Filter

doFilterInternal 方法中读取必要的请求头,进行验证。

@Component
public class OniFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                HttpServletResponse response,
                                FilterChain filterChain
        ) throws IOException, ServletException {
        String nonce = request.getHeader("X-Ca-nonce");
        long sentTimestamp = Long.parseLong(
            request.getHeader("X-Ca-timestamp"));
        // do your filter logic here
        // ...
        filterChain.doFilter(request, response);
    }
}
  • 当指定的两个请求头不存在或解析异常,抛出「请求构造异常」异常
  • 如果 sentTimestamp 与系统当前时间相差过大,抛出「请求过期」异常
  • 把接收到的 Nonce 存放在缓存中,设置一个过期策略,当新请求提交的 Nonce 在缓存中能够命中时,抛出「请求过多」异常

这个时候核心功能就完成了。

处理异常

一般而言我们推荐直接在出现问题的地方抛出异常,然后通过 @RestControllerAdviceExceptionHandler 声明如何处理异常,向调用方返回特定的数据结构(例如 ProblemDetail)。

不过我们现在写的是 Web Filter,而 Filters 总是在 Servlet 触发前执行,因此指望 DispatcherServlet 中的 @ControllerAdvice 是不太可能的。

如果使用了 Spring Security filter chain,可以定义 FilterChainExceptionHandler 来处理。

我在这里选择注入 HandlerExceptionResolver,产生异常时不直接抛出,而是传递给其 HandlerExceptionResolver#resolveException 方法,从而利用已有的 ExceptionHandler

if (Math.abs(currentTimestamp - sentTimestamp) > MAX_DELAY_IN_SEC) {
    handlerExceptionResolver.resolveException(request, response, null,
        new RequestExpiredException());
    return;
}

组装 ProblemDetail 结构:

@ResponseStatus(HttpStatus.REQUEST_TIMEOUT)
@ExceptionHandler(RequestExpiredException.class)
public ProblemDetail handleExceptions(RequestExpiredException ex) {
    ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
        HttpStatus.REQUEST_TIMEOUT,
        "Request Expired");

基于 application.properties 的自定义配置

由于网络环境和应用场景的不同,X-Ca-timestamp 允许的超时时间也应该是不同的,我们希望可以在 application.properties 里自定义。此外如果请求中已经存在时间戳,也可以要求 OniFilter 转而读取这些字段……

最重要的是允许通过 enabled 属性简单地开关这个功能(否则调试接口的开发人员也许会顺着网线来找你)。

# 实际上用的 application.yml
# 没什么区别
oni:
  client-nonce-and-timestamp-check:
    enabled: true
    # 可接受的延时
    max-delay-in-second: 3
    # 需要读取的请求头
    # 根据规范,请求头是大小写不敏感的
    client-nonce-header: x-ca-nonce
    timestamp-header: x-ca-timestamp
    # 哪些 HTTP 方法访问接口时需要接受检查
    apply-on-methods:
      - POST
      - PUT

编写一个属性能够匹配上的 Properties 类和对应的 AutoConfiguration 类:

@ConfigurationProperties(value = "oni.client-nonce-and-timestamp-check")
public class FilterProperties {
    private boolean enabled = true;
    private long caffeineCacheSize = 10_000;
    private long maxDelayInSecond = 5;
    private String clientNonceHeader = Constant.DEFAULT_HEADER_NAME_FOR_CLIENT_NONCE;
    private String timestampHeader = Constant.DEFAULT_HEADER_NAME_FOR_TIMESTAMP;
    private List<String> applyOnMethods = List.of("POST");

@ConditionalOnProperty 说明 oni.client-nonce-and-timestamp-check.enabled=true 时才进行初始化和配置。

@Configuration
@ConditionalOnProperty(prefix = Constant.PROPERTIES_PREFIX,
                    name = Constant.PROPERTY_ENABLED,
                    havingValue = "true")
@EnableConfigurationProperties(FilterProperties.class)
public class FilterAutoConfiguration {
    private final FilterProperties filterProperties;

    public FilterAutoConfiguration(FilterProperties filterProperties) {
        this.filterProperties = filterProperties;
    }
}

接下来就可以在 OniFilter 中注入 FilterProperties 读取自定义配置了。

例如 oni.client-nonce-and-timestamp-check.apply-on-methods 指定 POST 方法和 PUT 方法需要经过过滤器检查,就可以使用 filterProperties.getApplyOnMethods().contains(requestMethods) 判断。

为了让代码清晰一些,我们把检查逻辑移动到 OniFilter#shouldNotFilter 方法中。

识别当前请求的 HandlerMethod

一般来说 GETDELETEPUT 方法都是幂等的,POST 方法的接口才需要检查重放。如果我们可以通过注解小范围地改变这个行为就好了。

创建一个 @Oni 注解,这个注解可以指定其修饰的 Controller 方法是否要检查重放参数。

@Target({METHOD})
@Retention(RUNTIME)
public @interface Oni {
    boolean enabled() default true;
}

在 OniFilter 中注入 RequestMappingHandlerMapping,可以获得 HandlerMethod 信息,然后进一步获取注解信息。

if (!ServletRequestPathUtils.hasParsedRequestPath(request)) {
    ServletRequestPathUtils.parseAndCache(request);
}
HandlerMethod handlerMethod;
try {
    handlerMethod = (HandlerMethod) requestMappingHandlerMapping.getHandler(request).getHandler();
} catch (Exception e) {
    // 获取请求 handler 失败,不做下一步验证
    return true;
}
Method method = handlerMethod.getMethod();
if (method.isAnnotationPresent(Oni.class)) {
    var annotation = method.getAnnotation(Oni.class);

每一次请求都要查询和解析对应的 Controller 方法和注解,还有优化的空间。

这部分逻辑也加入 OniFilter#shouldNotFilter

自定义缓存服务

通过 Caffeine 实现了客户端 Nonce 的本地缓存。AutoConfiguration 中构造了 CaffeineNonceCacheService 类,使用 Caffeine#newBuilder 初始化缓存,在 OniFilter 中注入了对应的 Bean。

if (nonceCacheService.isPresent(nonce)) {
    handlerExceptionResolver.resolveException(request, response, null,
        new TooManyRequestsException("Too many request"));
    return;
}

那么问题来了,现在我们的后端服务要部署两个实例,如何替换成一个支持分布式的缓存服务呢?

抽出 NonceCacheService 接口,OniFilter 只需要知道自己会获得一个 NonceCacheService 类型的 Bean 然后调用 NonceCacheService#isPresent 即可。

用户则通过该接口实现自己的缓存服务,例如 RedisNonceCacheService

通过 oni.client-nonce-and-timestamp-check.use-custom-cache 指定这个缓存服务类:

oni:
  client-nonce-and-timestamp-check:
    enabled: true
    use-custom-cache: cc.ddrpa.jwtshowcase.RedisNonceCacheService

我们使用条件注解在 AutoConfiguration 类中选择性地构造 NonceCacheService 类型实例:

@Bean
@ConditionalOnProperty(prefix = Constant.PROPERTIES_PREFIX,
                       name = Constant.PROPERTY_CUSTOM_CACHE_SERVICE_CLASS)
public NonceCacheService customNonceCacheService()
    throws ReflectiveOperationException {
    return (NonceCacheService)
        this.getClass().getClassLoader()
        .loadClass(filterProperties.getUseCustomCache())
        .getConstructor().newInstance();
}

@Bean
@ConditionalOnMissingBean(NonceCacheService.class)
public NonceCacheService defaultNonceCacheService() {
    return new CaffeineNonceCacheService(
        filterProperties.getMaxDelayInSecond(),
        filterProperties.getCaffeineCacheSize());
}