11

在Laravel中通常使用Illuminate\Http\Request::ip()方法来获取客户端的IP地址。但在某些情况下,它获取到的结果不一定是你所期望的,这些情况包括:

  • 你的应用部署在负载均衡后面
  • 你的应用使用了CDN加速
  • 你的应用部署在其它反向代理后面

那怎样才能获取正确的IP呢?在Laravel中可以使用fideloper/proxy拓展包来解决(本文只讨论Laravel 5.5及以后版本的情况,因为从该版本开始Laravel已经默认集成了该拓展包)。它提供了一个名为App\Http\Middleware\TrustedProxies的中间件,这个中间件可以帮助你设置可信任代理。比方说你的负载均衡服务器的IP是192.168.1.1,那你只需要将这个IP配置到$proxies属性里即可:

/**
 * The trusted proxies for this application.
 *
 * @var array|string
 */
protected $proxies = '192.168.1.1';

有些朋友就会问,我的负载均衡服务器IP不固定怎么办(比如AWS的ELB)?这种情况也能解决,但是需要十分谨慎。首先你需要配置你的应用服务器不响应任何非负载均衡过来的请求,这样做的目的是严格控制请求来源,保证所接收到请求是可信的(比如在AWS里面可以通过设置security groups来实现)。然后再将$proxies设置为*,表示始终信任上层代理进来的请求,即可。

当然,$proxies也可以是数组,如果你有多层反向代理,则需要可配置多个IP地址。这里的IP既可以是IPv4也可以是IPv6,并且可以使用CIDR风格的IP范围,比如:144.220.0.0/16

我本人就接手过一个项目,它的反向代理比上述情况更复杂:我们的应用部署在多个AWS云服务器实例之上,并由ELB进行负载均衡,由于该项目有全球访问的需求,我们在ELB前面还用CloudFront做了CDN加速。前面有介绍ELB的IP是非固定的,并且CloudFront的IP也是非固定。针对这种情况,我们只能逐一分析。对于ELB层,我们使用控制请求源并设置$proxies*即可。而对于CloudFront,好在AWS为开发者提供了CloudFront节点服务器的IP范围,所以我们只要将官网提供的CIDR信息配置到$proxies属性里面即可。当然CloudFront的IP范围可能随时会改变,所以我们会定时抓取接口并将结果缓存,以保证准确性和效率。

原理

了解了如何正确配置TrustedProxies,我们还要学习原理,知其所以然。分析一下App\Http\Middleware\TrustedProxies的源码,不难发现,这个中间件最终做的一件事情,就是调用Symfony\Component\HttpFoundation::setTrustedProxies()方法,将你配置的$proxies赋值到Symfony\Component\HttpFoundation类的$trustedProxies属性中去。看到这你也就明白了,其实这个功能实际是由底层的Symfony提供的,fideloper/proxy拓展包只是帮忙适配了一下Laravel而已(Symfony大法好呀🤘)。

接下来分析源码,打开文件vendor/symfony/http-foundation/Request.php,阅读一下这个方法:

public function getClientIps()
{
    $ip = $this->server->get('REMOTE_ADDR');

    if (!$this->isFromTrustedProxy()) {
        return [$ip];
    }

    return $this->getTrustedValues(self::HEADER_X_FORWARDED_FOR, $ip) ?: [$ip];
}

很容易理解,如果你未配置TrustedProxies或者这个请求不是来自可信任的代理,那么就直接返回REMOTE_ADDR地址,这也是为什么获取不到正确IP的原因。如果这个请求来自可信任代理,就会从X-Forwarded-For头中获取客户端的IP。

首先认识一下REMOTE_ADDR,它是服务器(nginx/apache)与客户端进行TCP连接时获取的真实客户端地址,是不可伪造的。比如你使用了负载均衡,那么在应用里获得的REMOTE_ADDR就是负载均衡服务器的地址,否则就是客户机的地址。所以isFromTrustedProxy()方法也是基于REMOTE_ADDR来做判断的。

然后是X-Forwarded-For,它是HTTP协议里常见的一个拓展头,用于记录从客户端到应用服务器之间所经过的代理服务器或者负载均衡的地址,包括客户端地址。格式如下:

X-Forwarded-For: client, proxy1, proxy2, proxy3

每一层代理服务器都会将上一层代理的地址追加到这个头里面来,也就是我们常在nginx配置文件中见到的这项配置:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

所以想要获取到真实的客户端IP,就需要通过这个头部来获取。但需要注意的是,X-Forwarded-For是可以被随意伪造的,比方说我随意构造一个HTTP请求:

$ curl -H "X-Forwarded-For: 192.168.1.1, 192.168.1.2, 192.168.1.3" https://example.com

正因为这种可伪造性,导致我们不能直接使用X-Forwarded-For里的第一个IP作为最终结果。不用担心,Symfony已经帮我们处理了这一切。关于Symfony具体的做法,感兴趣的朋友可以直接查看getTrustedValues()方法的源码,我大致描述一下过程:

首先从HTTP头部中取出X-Forwarded-ForForwarded的值生成IP列表。这里为什么会去取Forwarded头呢?事实上X-Forwarded-For目前不属于任何一份既有规范,这个消息首部的标准版本是Forwarded,格式如下:

Forwarded: by=<identifier>; for=<identifier>; host=<host>; proto=<http|https>

而Symfony兼顾了两种头部格式的处理,但如果这两头同时存在Symfony会抛出冲突异常,你可以通过设置Trusted Header移除其中一个来避免冲突异常。拿到IP列表后,再通过normalizeAndFilterClientIps()方法来滤出客户端IP列表。normalizeAndFilterClientIps()方法会将输入的IP一个一个地判断是否为开发者配置的可信任IP,如果是则从列表中移除,剩余的则是客户端IP列表。但特别重要的一点是,normalizeAndFilterClientIps()方法在返回结果的时候会调用array_reverse()方法将客户端IP列表进行逆序。也许你会有疑问,为什么要将结果逆序返回呢?明明协议中规定第一个才是“真实”的客户端IP,但恰恰是这个逆序,才保证了结果的安全。我们来举个实例就明白了:

假设我们服务器的反向代理链条是这样的:192.168.66.1 -> 192.168.66.2 -> 192.168.66.3,最后一个是应用服务器IP,并且我们的程序中已将192.168.66.1192.168.66.2添加到了可信任代理中。这时有个恶意用户访问了我们的站点,他的主机IP是192.168.1.1,他在访问我们的站点时构造了X-Forwarded-For

$ curl -H "X-Forwarded-For: 192.168.1.3, 192.168.1.2" https://example.com

这个恶意请求最终到达应用服务器后的X-Forwarded-For实际上是这样的:

X-Forwarded-For: 192.168.1.3, 192.168.1.2, 192.168.1.1, 192.168.66.1

程序在normalizeAndFilterClientIps()方法过滤掉可信任代理IP后,剩余的结果为:192.168.1.3, 192.168.1.2, 192.168.1.1。很显然,如果不进行逆序处理,我们使用Illuminate\Http\Request::ip()获取到的IP则是恶意用户构造的192.168.1.3,而逆序处理后获得的IP则是真实的192.168.1.1。所以这个逆序很关键。

了解上述原理以后,即使你不使用Laravel或者Symfony框架,也可以在自己的项目中实现正确的逻辑,而不是从某度CV一段错误的代码,让自己的应用面临风险。

配置文件

有些开发者喜欢讲将配置统一到config/目录下,而不是直接在中间件中进行配置,你只需要运行以下命令,就可以发布配置文件trustedproxies.php

$ php artisan vendor:publish --provider="Fideloper\Proxy\TrustedProxyServiceProvider"

当然,如果你有分环境配置的需求,可自行使用env()方法进行拓展。但是请注意,中间件里的$proxies属性是优先于配置文件的,当$proxies属性有值的时候,配置文件里设置的值将失效,请勿踩坑。


斯坦毅
72 声望4 粉丝