IP 地址过滤中间件
2018-05-14

出于安全性考虑,有些服务会限制调用方的 IP 地址。

比如公司内部不对外公开的服务,我们自己想要限制 IP。再比如有些按量付费的对外接口,客户担心自己的身份被冒用而要求我们限制调用方的 IP 地址,这时客户会给我们一个 IP 白名单,这是客户服务器的固定 IP,只有从这些服务器发出的请求才认为是合法的。

IP 过滤放在 API Gateway 里面去实现越来越成为主流,不过在业务代码里实现更加灵活,定制化程度也最高。因为不同的业务需求不一样,导致过滤的规则不一样、请求的响应格式不一样、特判条件不一样,所以统一处理的意义就不大了。而且也不是什么场景都需要一个 API Gateway 的。

本文就介绍一个 Node.js 的 IP 过滤中间件(两个版本):

IP 从哪里来

客户端发来的 HTTP 请求完全是由客户端构造的,无论是 header 还是 body 客户端想伪装成任何样子都可以。剩下唯一可以信任的就是 IP 地址了。IP 地址不在 HTTP 请求中,但经过 nginx 的时候,nginx 可以把 IP 地址附加到 HTTP header 里。

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

上面的配置中,第一行是在 header 设置一个名为 X-Real-IP 的字段,值是客户端 IP 地址。第二行则稍微复杂,如果没有 X-Forwarded-For 字段则添加这个字段并令其等于客户端 IP 地址,如果已经存在 X-Forwarded-For 字段,则在它的现有值的后面追加上客户端 IP 地址。

一个 HTTP 请求过来,假如客户端 IP 地址是 1.2.3.4,

如果没有 X-Forwarded-For 字段,那么 nginx 会给 HTTP header 这样设置:

X-Real-IP: 1.2.3.4
X-Forwarded-For: 1.2.3.4

如果有 X-Forwarded-For 字段,且值为 5.5.5.5,那么 nginx 会给 HTTP header 这样设置:

X-Real-IP: 1.2.3.4
X-Forwarded-For: 5.5.5.5, 1.2.3.4

我们发现 X-Forwarded-For 是在原值后面加了一个英文逗号,一个空格,然后才是客户端的 IP 地址。这样做的目的是为了追溯更详细的转发过程。但要保持警惕,因为 X-Forwarded-For 前面的值有可能是从客户端发来的。

现在情况变得有些复杂,因为涉及到一个是否要信任 X-Forwarded-For 的问题。如果涉及到安全,是不该信任客户端的,所以入口 nginx 服务器上,就这样配置:

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;

注意,这次 X-Forwarded-For 的值被直接设置为客户端 IP 地址,抹掉了之前客户端携带的 X-Forwarded-For。

私有地址(内网 IP)

IP 过滤的一大目的之一,就是保护内部接口不对外暴露,通常一个公司的服务都在同一个内网,所以只允许内网 IP 访问就是最简单的防御策略。

接下来问题来了,哪些 IP 地址属于内网地址呢?

性能和其他权衡

判断是否在 IP 黑白名单这种场景,首先想到的就是查 Redis。但仔细想想也许放在内存更好。因为 IP 黑白名单一般不会有太多,在内存里不会有空间问题,要重点解决的可能是当黑白名单变化时如何同步到进程的内存里。

目前一些做的比较好的配置管理工具例如 consul 都有监听变化的功能,所以热更新也不是难事。在这种情况下,放在内存中就没什么问题了。而且内存中因为代码是同步的(而不是像 Redis 那样的 IO 操作),无论代码的执行效率还是写代码的简洁程度均有优势。在这样的考虑下,我选择了把黑白名单放在内存中管理,数据结构则采用了最适合做这件事的 Set。

基本用法

const Koa = require('koa')
const restrictIp = require('@zhike/restrict-ip-koa-middleware')

const whitelistRestrict = restrictIp({
  whitelist: new Set(['2.2.2.2', '3.3.3.3'])
})

const app = new Koa()
app.use(whitelistRestrict)

功能

默认行为

默认取 IP 地址的次序

如果不设置 trustedHeaderSequence,默认取 IP 地址的次序是:

  1. HTTP header 里的 x-forwarded-for 中最左边的 IP 地址
  2. HTTP header 里的 x-real-ip
  3. 直接 IP,即 ctx.ip

默认拦截行为

如果不设置 onRestrict 方法,需要拦截的时候,默认会抛出一个默认 Error:

  1. 需要拦截的时候,默认抛出 Error
let err = new Error('IP restricted');
err.ip = ipToCheck;
throw err;

默认 Error 的特征:具有固定的 message: “IP restricted”,另有 ip 字段为被拦截的 IP 地址。

测试用例

  白名单外网地址
    ✓ 在白名单,通过
    ✓ 不在白名单,拦截

  白名单且允许内网地址
    ✓ 不在白名单,但是本机地址 通过
    ✓ 不在白名单,但是是 A 类内网地址 通过
    ✓ 不在白名单,但是是 B 类内网地址 通过
    ✓ 不在白名单,但是是 C 类内网地址 通过
    ✓ 不在白名单,也不是内网地址 拦截

  黑名单策略
    ✓ 不在黑名单 通过
    ✓ 在黑名单 拦截

  自定义函数拦截
    ✓ 自定义拦截函数 通过
    ✓ 自定义拦截函数 拦截

  自定义方式获取 IP 地址
    ✓ trustedHeaderSequence 不指定,默认先 x-forwarded-for 后 x-real-ip
    ✓ trustedHeaderSequence 按指定顺序
    ✓ trustedHeaderSequence 为空数组,看直接 IP
END