问题
在多级代理下,后端服务器如何获取客户的真实 IP。下面通过案例的形式,给出 Nginx 的两种实现方案。
环境说明
- 192.168.0.1 客户端 IP
- 192.168.0.5 Nginx Proxy1 一级代理
- 192.168.0.6 Nginx Proxy2 二级代理
- 192.168.0.7 Nginx Proxy3 三级代理
- 192.168.0.10 Nginx Web 后端 Web 服务器
目标
多级代理下透传客户端 IP,后端 Web 服务器能获取到客户端的真实 IP 192.168.0.1。
实施
一级代理 Nginx Proxy1 配置如下:
server {
listen 80;
server_name foo.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://192.168.0.6:80;
}
}
二级代理 Nginx Proxy2 配置如下:
server {
listen 80;
server_name foo.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://192.168.0.7:80;
}
}
三级代理 Nginx Proxy3 配置如下:
server {
listen 80;
server_name foo.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://192.168.0.10:80;
}
}
后端 Nginx Web 配置如下:
server {
listen 80;
server_name foo.com;
root /www/web/foo;
index index.html index.htm index.php;
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass 127.0.0.1:9000;
}
}
修改 hosts,将 foo.com 指向一级代理 Nginx Proxy1。
192.168.0.5 foo.com
foo.com 站点根目录下创建一个 index.php 文件用于测试。
<?php
echo $_SERVER['HTTP_X_FORWARDED_FOR'], PHP_EOL;
echo $_SERVER['REMOTE_ADDR'], PHP_EOL;
验证
请求 foo.com 站点
suhua@g7-7588:~$ curl http://foo.com/index.php
验证方法1:观察各节点 Nginx 的访问日志,结果如下:
# Nginx Proxy1
192.168.0.1 - - "GET /index.php HTTP/1.1" 200 "-"
# Nginx Proxy2
192.168.0.5 - - "GET /index.php HTTP/1.0" 200 "192.168.0.1"
# Nginx Proxy3
192.168.0.6 - - "GET /index.php HTTP/1.0" 200 "192.168.0.1, 192.168.0.5"
# Nginx Web
192.168.0.7 - - "GET /index.php HTTP/1.0" 200 "192.168.0.1, 192.168.0.5, 192.168.0.6"
验证方法2:打印出后端 Web 服务器接收到的 X-Forwarded-For 变量的值
suhua@g7-7588:~$ curl http://foo.com/index.php
192.168.0.1, 192.168.0.5, 192.168.0.6
192.168.0.7
$_SERVER['HTTP_X_FORWARDED_FOR']
最左侧的 IP 192.168.0.1 就是客户端 IP,后端 Web 服务器已经成功获取到客户端 IP,实验验证成功。
另外,值得注意的是,后端服务器打印出的 remote_addr 变量,此时是上游服务器即三级代理 Nginx Proxy3 的 IP。而在客户端直连后端服务器的时候,remote_addr 会是客户端的真实 IP。在概念上,个人认为 remote_addr 是指上游 IP 而不是客户端 IP,这样的定义更好理解。
安全问题
由于用户可以随意伪造 X-Forwarded-For,所以最左侧的 IP 并不一定就是客户端的 IP,而且还可能存在一些恶意的脚本,比如用户通过以下方式伪造 X-Forwarded-For:
curl http://foo.com/index.php -H 'X-Forwarded-for: <xss/>1.1.1.1'
此时后端服务器接收到的 X-Forwarded-For 值如下:
<xss/>1.1.1.1, 192.168.0.1, 192.168.0.5, 192.168.0.6
为了能够获取到一个真实并且安全的客户端 IP,后端服务器在读取 X-Forwarded-For 时,应从右到左寻找第一个不属于代理服务器 IP 的合法 IP,该 IP 就是客户端的真实 IP。注意是从右到左,而且一定是合法的,同时需要将代理服务器 IP 排除掉。
使用 PHP 实现如下:
<?php
function get_client_ip($trusted_ips = []) {
$real_ip = '0.0.0.0';
if (empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
return $real_ip;
}
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
foreach (array_reverse($ips) as $ip) {
$ip = trim($ip);
if (filter_var($ip, FILTER_VALIDATE_IP) && !in_array($ip, $trusted_ips)) {
$real_ip = $ip;
break;
}
}
return $real_ip;
}
使用方法:
<?php
// 所有的代理服务器 IP
$trusted_ips = [
'192.168.0.5',
'192.168.0.6',
'192.168.0.7',
];
echo get_client_ip($trusted_ips), PHP_EOL;
// 输出结果:
192.168.0.1
使用 realip 模块实现
实际上为了使用上的简单起见,Nginx 提供了一个 ngx_http_realip_module 模块,该模块会自动完成对客户端真实 IP 的提取,提取出来后会赋值给 remote_addr
变量,而后端直接使用 remote_addr
变量即可。
为了启用 realip 模块,只需修改后端 Nginx Web 服务器的配置即可,代理服务器配置不需要修改。
server {
listen 80;
server_name foo.com;
root /www/web/foo;
index index.html index.htm index.php;
set_real_ip_from 192.168.0.5;
set_real_ip_from 192.168.0.6;
set_real_ip_from 192.168.0.7;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
# $realip_remote_addr 变量会记录上游服务器的 IP,
# 由于 remote_addr 被修改成客户端 IP,所以后端可以通过该变量获取上游 IP。
fastcgi_param REALIP_REMOTE_ADDR $realip_remote_addr;
fastcgi_pass 127.0.0.1:9000;
}
}
此时再次访问 foo.com,观察后端 Web 服务器的访问日志和 remote_addr
值的变化
suhua@g7-7588:~$ cat /etc/logs/nginx/access.log
192.168.0.1 - - "GET /index.php HTTP/1.0" 200 "192.168.0.1, 192.168.0.5, 192.168.0.6"
suhua@g7-7588:~$ curl http://foo.com/index.php
192.168.0.1, 192.168.0.5, 192.168.0.6
192.168.0.1
从以上的输出可以观察到,remote_addr 从上游服务器 IP 192.168.0.7 变成了客户端 IP 192.168.0.1,而 X-Forwarded-For 并没有发生变化。因此,在使用了 realip 模块后,后端服务器就可以直接使用 remote_addr 当作客户端 IP。
此时,PHP 获取客户端 IP 的方法:
<?php
function get_client_ip() {
return $_SERVER['REMOTE_ADDR'];
}
实际上,采用了 realip 模块后,后端依然可以从 X-Forwarded-For 获取客户端的 IP,上面介绍的从 X-Forwarded-For 提取客户端 IP 的方法依然有效。即后端既可以直接使用 remote_addr 变量,也可能自行从 X-Forwarded-For 变量中分析并提取。
另外,Nginx 提取客户端 IP 的过程,跟我们上面自己写 PHP 所实现的方法一样,只不过 Nginx 在 realip 模块中做了同样的实现。
下面对 realip 几个变量进行一些说明:
- set_real_ip_from 添加已知的(可信任的)代理服务器的 IP,好让 Nginx 在提取客户端 IP 时过滤掉这些 IP;
- real_ip_header 指定应分析哪个请求头来获取客户端 IP,默认是 X-Real-IP,但一般用 X-Forwarded-For;
real_ip_recursive 默认值为 off,off 的行为不好解释,因为我不太清楚它的使用场景。当为 on 时,Nginx 会分析 real_ip_header 所指定的请求头变量,并从右到左寻找第一个合法并且不属于 set_real_ip_from 所设置的可信任的服务器 IP 的 IP,最后将该 IP 当作客户端 IP 并将它赋值给 remote_addr 变量。
参考文献
- nginx real_ip_header and X-Forwarded-For seems wrong
- nginx 如何配置来获取用户真实IP
- Module ngx_http_realip_module