先说结论:
- 说明:一切抛开网络拓扑提准确获取用户ip的通用方案都是耍流氓。
- 根据网络拓扑当服务器架构中的设备IP加入可信IP列表,成本:持续维护可信IP区、与拓扑相关、增加防火墙WAF调整可信IP
- 剔除可信IP列表
- 从XFF头部倒序获取首个非可信IP作为客户端真实IP,防伪造XFF、防代理、保正常请求
- Laravel/Nginx-realip均按上述逻辑来处理的
网络拓扑模型
前提:
- 多用户公用wifi场景下,多用户公用出口IP,这种场景下无法区分用户ip
- 通过vpn、匿名代理方式访问,只能获取到vpn的出口IP
- TCP有连接、三次握手的特性决定了TCP直连IP地址无法被篡改,在茫茫网络中可信的只有上一跳
- 获取用户IP的目的一般为:精准推荐(如根据位置推荐信息)、反作弊(防刷)
网络中几个节点及ip地址说明:
- Client:客户端,请求发起方,ip地址为ip0
- SLB:服务器负载均衡,在阿里云解决方案中负载均衡SLB提供传递客户真实ip到x-forwarded-for的设置
- 一般情况下,SLB是单IP入口,多IP出口(SLB的内部实现也是多台机器的集群,所以出口都是多IP的)
- SLB分公网SLB和私网SLB,二者表征基本一致,区别在于入口IP是否为公网IP:
- SLB的出口IP(回源IP)一般是共享IP地址,如
100.109.22x.x
- SLB的出口IP(回源IP)一般是共享IP地址,如
- 注意:SLB需要正确配置x-forwarded-for,才能透传用户ip信息
Nginx:web server程序,将请求转发给不同的后端应用程序(php/java/go等)
- Nginx中
$remote_addr
:为Nginx建立TCP连接的直连IP地址(TCP三次握手情况下,无法伪造) - Nginx中
$http_x_forwarded_for
:获取HTTP请求中x-forwarded-for头部
通用的日志格式:
1
2
3log_format main 'remote_addr=[$remote_addr] http_x_forward=[$http_x_forwarded_for] time=[$time_local] request=[$request] '
'status=[$status] byte=[$bytes_sent] elapsed=[$request_time] refer=[$http_referer] body=[$request_body] '
'ua=[$http_user_agent] cookie=[$http_cookie] gzip=[$gzip_ratio] log_id=[$hostname$pid$msec$request_length$http_x_forwarded_for$connection]';
- Nginx中
如何正确获取用户ip
说明:一切抛开网络拓扑提准确获取用户ip的通用方案都是耍流氓。
在不同的网络拓扑下,不同网络设备的转发处理差异,都可能影响到最终获取用户ip的准确性。
这里将用户请求分为四类:
- 情形A:普通用户ip0->通过slb0->导致nginx,正常用户请求
- 情形B:普通用户ip0->用户携带伪造的x-forwarded-for头部->通过slb0->导致nginx,正常用户请求
- 情形C:普通用户ip0->用户使用代理->通过slb0->导致nginx,正常用户请求
- 情形D:普通用户ip0->携带伪造x-forwarded-for->用户使用代理->通过slb0->导致nginx,正常用户请求
情形A: 用户正常请求
网络拓扑如下:
各节点处理操作:
- Client:以ip0发起请求,和slb0地址建立连接
- SLB:入口slb0接收请求,出口slb-out转发至后端服务上;将用户ip0增加到x-forward-for头部
- Nginx:获取用户请求,按转发逻辑将请求发送给处理程序
在这种情形下,以PHP为例,获取到的请求形式将如下:
- REMOTE_ADDR = slb-out
- X-FORWARDED-FOR = ip0
所以在正常情况下,取$_SERVER['HTTP_X_FORWARDED_FOR']
就是用户的真是ip0
特别注意:任何时候正常逻辑的处理都是比较容易实现的,但防范非正常用户才是重点。
情形B: 用户携带伪造的x-forwared-for头部
携带伪造的x-forwarded-for,对发起请求放来说几乎无成本:
1 | curl -X GET http://127.0.0.1:9999/test -H 'x-forwarded-for: 1.2.3.4' |
网络拓扑如下:
各节点处理操作:
Client:以ip0发起请求,和slb0地址建立连接,同时发送HTTP HEADER:
x-forwarded-for: ipx
- 注意:这里
ipx
可以是任意随机ip地址,一个、多个都可以
- 注意:这里
SLB:入口slb0接收请求,出口slb-out转发至后端服务上;将用户ip0追加到x-forward-for头部后
- 此时的
x-forwarded-for=ipx, ip0
1
remote_addr=[100.109.222.5] http_x_forward=[1.2.3.4, 2.2.2.2, 36.110.86.210] time=[09/Jan/2018:15:41:40 +0800] request=[GET /health?hello=99999 HTTP/1.0] status=[404] byte=[4728] elapsed=[0.004] refer=[-] body=[-] ua=[PostmanRuntime/7.1.1] cookie=[-] gzip=[-]
- IP地址
100.109.222.5
为SLB出口IP(又称回源IP) - 上述请求中携带了伪造的头部
x-forwarded-for: 1.2.3.4, 2.2.2.2
- IP地址
36.110.86.210
为本机的公网IP,通过VPN访问私网SLB时拿到IP为10.51.64.132
- 此时的
Nginx:获取用户请求,按转发逻辑将请求发送给处理程序
在这种情形下,以PHP为例,获取到的请求形式将如下:
- REMOTE_ADDR = slb-out
- X-FORWARDED-FOR =
ipx, ip0
在这种情形下,无论REMOTE_ADDR
还是XX-FORWARDED-FOR[0]
都不是用户有效ip,且X-FORWARDED-FOR[0]
这种形式被伪造ipx欺骗,风险极大。
情形C: 用户挂代理访问
代理其实是反屏蔽的有效武器,所以要特别考虑这种情形:
- Proxy:入口ip为proxy0,出口为proxy-out
- 普通代理:将ip0增加到x-forwarded-for的Header中
- 匿名代理:将用户请求的ip0抹掉,不做任何x-forwarded-for头部处理
在这种情况下,最终到达Nginx后的情形如下,匿名代理时由于用户ip已被代理抹去,所以只能获取到proxy-out地址(其实从反作弊的角度看,这就足够了)
- Nginx:
- 普通代理:
x-forwarded-for=ip0, proxy-out
- 匿名代理:
x-forwarded-for=proxy-out
- 普通代理:
情形D: 用户伪造x-forwarded-for请求头+挂代理
- Proxy:
- 普通代理:将ip0追加到x-forwarded-for的Header中,此时
x-forwarded-for=ipx, ip0
- 匿名代理:将用户请求的ip0抹掉,不做任何x-forwarded-for头部处理
- 普通代理:将ip0追加到x-forwarded-for的Header中,此时
在这种情况下,最终到达Nginx后的情形如下,匿名代理时由于用户ip已被代理抹去,所以只能获取到proxy-out地址(其实从反作弊的角度看,这就足够了)
- Nginx:
- 普通代理:
x-forwarded-for=ipx, ip0, proxy-out
- 匿名代理:
x-forwarded-for=proxy-out
- 普通代理:
最佳实践
在复杂网络环境下,伪造HTTP头部、挂代理、甚至是增加网络设备(如防火墙)都可能导致网络拓扑变化,从而影响到获取用户IP的有效性。
(敲敲黑板)那么,什么方案才是获取用户ip的最佳方案呢?
Laravel方案
- 结论:
- Laravel中
getClientIp()
方案必须配合Request::setTrustedProxies(['1.1.1.1', '2.2.2.2/18']);
- 合理设置可信代理区(支持ip或CIDR形式),将首个非可信IP地址作为用户IP
- x-forwarded-for=ip0, ip1, ip2, remote_addr=ip3
- 则通过getClientIp()获取到的ip= [ip0, ip1, ip2, ip3],假设可信代理ip=[ip2, ip3],则删除可信ip后为[ip0, ip1],做一次数组翻转,getClientIps()为[ip1, ip0],getClientIp()返回ip=ip1
- 好处:既考虑各种网络模型的影响,也兼顾代理、伪造x-forwarded-for头部的请求情况,是较为理想的方案
- Laravel中
- Laravel中提供的方式是$request->getClientIp()和getClientIps()函数
isFromTrustedProxy()
函数:判断remote_addr是否在可信代理列表中,不在则直接返回getTrustedValues()
函数:- 默认TrustedHeader为
x-forwarded-for
- 函数名叫获取可信值,其实做得事情是剥离可信代理,返回不可信的IP地址列表,吐槽下函数名+无注释
- 默认TrustedHeader为
normalizeAndFilterClientIps(array $clientIps, $ip)
函数:- 将remote_add追加到x-forwarded-for的尾部
- 从前到后遍历x-forwarded-for字段,标记首个可信代理ip并最终移除可信代理IP
- 现在结果将是ip0, proxy1, proxy2这种形式,至此只保留不信任IP和remote_addr
- 最终将此结果做下array_reverse翻转操作,方便外层获取第0个元素作为最接近可信区的ip地址
- 主要处理逻辑
1 | /** |
Nginx中realip模块
- openresty增加realip模块
1 | --with-http_realip_module |
- 修改nginx.conf配置文件
1 | set_real_ip_from 0.0.0.0/0; |
此时,Nginx将XFF作为real_ip_header头部信息,将从收到请求的XFF中获取真实IP
- 取XFF
- 从XFF中自后向前(从右向左),剔除set_real_ip_from的地址,如果设置了
real_ip_recursive on
,则继续剔除 - 将首个不在
set_real_ip_from
中地址,写入$remote_addr
,此时已经时首个非信任地址
1 | remote_addr=[36.110.86.210] http_x_forward=[1.1.1.1, 36.110.86.210] time=[09/Jan/2018:17:18:45 +0800] request=[GET /?hello=99999 HTTP/1.0] status=[302] byte=[241] elapsed=[0.003] refer=[http://101.200.96.229/?hello=99999] body=[-] ua=[PostmanRuntime/7.1.1] cookie=[-] gzip=[-] msec=[1515489525.815] http_host=[101.200.96.229] http_accept=[*/*|gzip, deflate|-] upstream_response_time=[0.001] sent_http_set_cookie=[-] session_id=[-] rrc_tg=[-] |
总结
Laravel
和Nginx realip
模块的处理逻辑如出一辙,都需要设置可信代理区的IP地址范围,从而反向获取首个非信任IP作为客户端IP,在各种伪造、攻击请求、代理的网络模型下均能相对正确的获取客户端IP。
特别注意:网络拓扑中拓扑变化时,需要及时更新可信IP代理区。
比如,增加防火墙设备(如阿里云WAF应用防火墙)后,XFF中将增加一个WAF回源IP地址,也需要将防火墙的回源IP添加至可信IP代理内。