HTTP 请求中的 X-Forwarded-For

最近某个私人产品的服务器被一个无聊的小黑客攻击了,主要表现是在短时间内调用了几千次我们发送短信验证码的接口,致使运营商觉得我们的产品有异常情况从而暂时停止了我们的短信发送功能。

请求短信验证码的功能是以前某个哥们做的,我记得他当时说过有考虑过这样的情况。昨天看了代码后才得知他采用的只是限制每个电话号码在每天所允许接受的短信条数的功能,而且漏洞一大堆,因此想要攻击那也就是轻而易举了。没办法,老夫只能靠自己想想办法缓解这个问题了。

对于这个问题已经有无数的讨论,但对于一般的应用场景来说,无非就是限制每个电话的条数、限制每个 IP 每天所能发送的最大条数、图形或其他形式的验证码之类的方法。

由于老夫根本不是做安全相关的工作,对于这方面知之甚少,所以也只能临时抱佛脚现学现卖了。那么这篇日志主要来讨论一下如何针对 IP 地址做出限制。

服务器要获取客户端的 IP 地址,可以通过 TCP 连接的信息来获取,例如 Node.JS 的获取方式就是 req 请求对象的 connection.remoteAddress 属性里。这个地址是可靠的,不能够被伪造,因为如果伪造了的话,TCP 的三次握手就会失败,会话就建立不起来。不过可惜的是现在客户端几乎不会直接和后端的业务服务器直接通信,中间都会由代理服务器层层转发。

例如,我们的所有来自客户端的连接都要走 Nginx 反向代理,Nginx 收到请求后按照 URL 以及其他条件转发给后端的业务服务器。此时,业务服务器所获取到的 Remote Address 就是 Nginx 服务器的地址。如果服务端部署的层次较多,Remote Address 就是最后一次转发的代理服务器的地址,而不是客户端的地址。

为此,在 RFC 7239 的 Forwarded HTTP Extension 标准中,正式定义了 X-Forwarded-For 这个请求头。这个请求头的标准格式如下:

X-Forwarded-For: client-ip, proxy-1, proxy-2

可以看出,每经过一层代理服务器,代理服务器就会在这个请求头的末尾添加上与自己连接的最后一层代理服务器的地址,表示“为它转发”,而自己的地址可以通过 Remote Address 得到。这样,客户端的真实地址就是第一个地址 client-ip。

不过,使用请求头做安全方面的验证是根本不安全的。因为请求头可以在转发的过程中被随意更改。例如与客户端直连的 proxy-1 是攻击者的代理服务器,那么它只要把这个请求头改成 1.1.1.1,那么服务端就会以为客户端的 IP 地址是修改后的 1.1.1.1 了。因此,如果我们只是采用 X-Forwarded-For 这个请求头的第一个地址作为客户端 IP 的限制,那么就更容易受到攻击了。它只要把消息的头部直接加上一个假的 IP 就行了。

那么,我们只能相信我们第一层 Nginx 服务器所连接的 Remote Address 地址(我们假定我们自己的服务器都是可信的并且没有bug,当然这个要求在复杂的环境中其实不好达到)。所以我们如果按照下面这个方式配置第一层 Nginx:

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

把与第一层 Nginx 连接的客户端的真实 IP 添加到 X-Real-IP 这个头部中(这也是一个自定义的头部),另外按照 RFC 7239 的标准处理好 X-Forwarded-For 这个头部记录代理服务器的信息。后面的其他代理仅添加 X-Forwarded-For 头部,这样最后的业务服务器就可以根据 X-Real-IP 这个头部获取到客户端的 IP 地址。但是我们需要考虑到:

第一,采用这种方式获取到的 X-Real-IP 是与第一层 Nginx 进行连接的客户端地址,这也可能是代理服务器而非客户端的真实 IP(例如公司网络出口都是由同一个代理服务器发出的,又比如在某些国家使用代理服务器进行网络访问很普遍)。也就是说,如果用户使用了代理,X-Real-IP 就会是代理服务器的地址。

如果我们的服务器需要提供个性化的信息,比如根据用户位置进行推荐,那么 X-Real-IP 的信息就是不对的。这样我们就只能使用 X-Forwarded-For 这个头部的第一条记录了。不过由于不牵扯到安全性的问题,用户造假了也没有啥影响。

第二,HTTP 的头部是可以随意设置的。如果客户端可以直接和后端的服务器连接而不走我们自己的代理,那么一定不能使用请求头的任何内容进行安全性验证。在上面的例子中,由于我们所有客户端都会走 Nginx 代理,而 X-Real-IP 是第一层代理设置的(如果已经有这个请求头就会被覆盖掉),因此可以认为它是可信的。反之,如果是直连方式,还是只能通过 Remote Address 来判断。

因此目前普遍的做法是,客户端只允许与最外层的 Nginx 代理进行连接,并且要屏蔽掉其他的端口防止暴露内部的服务接口。

第三,我们可能需要拿请求头的 IP 地址做一些数据库操作,比如记录用户 IP 到数据库等。由于请求头的内容不可信,那么需要防范在存储 SQL 过程中可能会有 SQL 注入的情况(还比如可能发生 XSS 攻击)。所以一定要判断请求头的 IP 是有效的 IP 格式后,才能进行这方面的处理。

这样老夫就可以采用这种方式来进行 IP 地址的限制了。但考虑到客户端使用代理服务器的情况,如果仅仅把 X-Real-IP 作为客户端的 IP 进行限制,那显然不太合适。比如你限制每个 IP 每天只能发 5 次验证码,那么假如一个公司里面有很多人都在用你这个产品,由于我们第一层 Nginx 设置的 X-Real-IP 是他们的代理服务器的地址,这样就意味着对于这个公司所有人每天只能发 5 次验证码了,用户又会开始喷了。

所以我们最好分开处理,如果没有检测到代理,则按照单个 IP 的限制进行处理。如果检测到了代理(比如 X-Forwarded-For 里面有不是服务器端的代理服务器 IP 的情况),则需要使用其他限制(比如除了限制 X-Forwarded-For 的第一个 IP 只能发 5 条外,还要限制每个代理服务器每天只能发 100 条)。

PS:考虑到某些攻击者可以在网上搜一大堆代理池的问题,我们可以综合采用 X-Forwarded-For 以及 X-Real-IP 的限制条件,即对 X-Forwarded-For 里面的 IP 以及 X-Real-IP 都进行次数限制。这样如果是网上搜出的匿名代理由于控制权不在他那里,所以绝大部分的代理应该都会正常处理 X-Forwarded-For,所以还是能够有所限制。如果他要找一大面积不添加 X-Forwarded-For 的代理,成本显然大大增加了。

✏️ 有任何想法?欢迎发邮件告诉老夫:daozhihun@outlook.com