不同网络拓扑下如何正确获取客户端ip?

先说结论:

  • 说明:一切抛开网络拓扑提准确获取用户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需要正确配置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
    3
    log_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]';

如何正确获取用户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头部处理

在这种情况下,最终到达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中提供的方式是$request->getClientIp()和getClientIps()函数
    • isFromTrustedProxy()函数:判断remote_addr是否在可信代理列表中,不在则直接返回
    • getTrustedValues()函数:
      • 默认TrustedHeader为x-forwarded-for
      • 函数名叫获取可信值,其实做得事情是剥离可信代理,返回不可信的IP地址列表,吐槽下函数名+无注释
    • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
/**
* Returns the client IP addresses.
*
* In the returned array the most trusted IP address is first, and the
* least trusted one last. The "real" client IP address is the last one,
* but this is also the least trusted one. Trusted proxies are stripped.
*
* Use this method carefully; you should use getClientIp() instead.
*
* @return array The client IP addresses
*
* @see getClientIp()
*/
public function getClientIps()
{
$ip = $this->server->get('REMOTE_ADDR');

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

return $this->getTrustedValues(self::HEADER_CLIENT_IP, $ip) ?: array($ip);
}

/**
* Returns the client IP address.
*
* This method can read the client IP address from the "X-Forwarded-For" header
* when trusted proxies were set via "setTrustedProxies()". The "X-Forwarded-For"
* header value is a comma+space separated list of IP addresses, the left-most
* being the original client, and each successive proxy that passed the request
* adding the IP address where it received the request from.
*
* If your reverse proxy uses a different header name than "X-Forwarded-For",
* ("Client-Ip" for instance), configure it via the $trustedHeaderSet
* argument of the Request::setTrustedProxies() method instead.
*
* @return string|null The client IP address
*
* @see getClientIps()
* @see http://en.wikipedia.org/wiki/X-Forwarded-For
*/
public function getClientIp()
{
$ipAddresses = $this->getClientIps();

return $ipAddresses[0];
}

private function getTrustedValues($type, $ip = null)
{
$clientValues = array();
$forwardedValues = array();

if (self::$trustedHeaders[$type] && $this->headers->has(self::$trustedHeaders[$type])) {
foreach (explode(',', $this->headers->get(self::$trustedHeaders[$type])) as $v) {
$clientValues[] = (self::HEADER_CLIENT_PORT === $type ? '0.0.0.0:' : '').trim($v);
}
}

if (self::$trustedHeaders[self::HEADER_FORWARDED] && $this->headers->has(self::$trustedHeaders[self::HEADER_FORWARDED])) {
$forwardedValues = $this->headers->get(self::$trustedHeaders[self::HEADER_FORWARDED]);
$forwardedValues = preg_match_all(sprintf('{(?:%s)=(?:"?\[?)([a-zA-Z0-9\.:_\-/]*+)}', self::$forwardedParams[$type]), $forwardedValues, $matches) ? $matches[1] : array();
}

if (null !== $ip) {
$clientValues = $this->normalizeAndFilterClientIps($clientValues, $ip);
$forwardedValues = $this->normalizeAndFilterClientIps($forwardedValues, $ip);
}

if ($forwardedValues === $clientValues || !$clientValues) {
return $forwardedValues;
}

if (!$forwardedValues) {
return $clientValues;
}

if (!$this->isForwardedValid) {
return null !== $ip ? array('0.0.0.0', $ip) : array();
}
$this->isForwardedValid = false;

throw new ConflictingHeadersException(sprintf('The request has both a trusted "%s" header and a trusted "%s" header, conflicting with each other. You should either configure your proxy to remove one of them, or configure your project to distrust the offending one.', self::$trustedHeaders[self::HEADER_FORWARDED], self::$trustedHeaders[$type]));
}

private function normalizeAndFilterClientIps(array $clientIps, $ip)
{
if (!$clientIps) {
return array();
}
$clientIps[] = $ip; // Complete the IP chain with the IP the request actually came from
$firstTrustedIp = null;

foreach ($clientIps as $key => $clientIp) {
// Remove port (unfortunately, it does happen)
if (preg_match('{((?:\d+\.){3}\d+)\:\d+}', $clientIp, $match)) {
$clientIps[$key] = $clientIp = $match[1];
}

if (!filter_var($clientIp, FILTER_VALIDATE_IP)) {
unset($clientIps[$key]);

continue;
}

if (IpUtils::checkIp($clientIp, self::$trustedProxies)) {
unset($clientIps[$key]);

// Fallback to this when the client IP falls into the range of trusted proxies
if (null === $firstTrustedIp) {
$firstTrustedIp = $clientIp;
}
}
}

// Now the IP chain contains only untrusted proxies and the client IP
return $clientIps ? array_reverse($clientIps) : array($firstTrustedIp);
}

Nginx中realip模块

  • openresty增加realip模块
1
--with-http_realip_module
  • 修改nginx.conf配置文件
1
2
3
4
5
6
    set_real_ip_from 0.0.0.0/0;
real_ip_header X-Forwarded-For;
include blockips.conf;

// blockips.conf
deny 120.26.57.4;

此时,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=[-]

总结

LaravelNginx realip模块的处理逻辑如出一辙,都需要设置可信代理区的IP地址范围,从而反向获取首个非信任IP作为客户端IP,在各种伪造、攻击请求、代理的网络模型下均能相对正确的获取客户端IP。

特别注意:网络拓扑中拓扑变化时,需要及时更新可信IP代理区。

比如,增加防火墙设备(如阿里云WAF应用防火墙)后,XFF中将增加一个WAF回源IP地址,也需要将防火墙的回源IP添加至可信IP代理内。

参考阅读