刷新页面能拿到真实 IP,为什么从首页点进列表就变成了 Docker 网关?
前段时间排查了一个挺有意思的问题。
站点上有个馆藏查询页,页面底部会显示当前访问 IP。某天有人反馈:
从首页搜索框搜东西,跳进结果页,底部显示的是
DOCKER_GATEWAY_IP;但在结果页按 F5 刷新一下,IP 就正常了。
一开始我以为是前端缓存,或者 useAsyncData 没刷新。结果打开浏览器 Network 一看,/api/catalog-access 的响应里明明白白写着:
{"ip":"DOCKER_GATEWAY_IP","allowed":true}好,前端不背这个锅。
问题出在请求到达 Nuxt 的时候,Nuxt 看到的来源地址就是 Docker 网关。
说明:文中的
library.example.com、DOCKER_GATEWAY_IP、WAF_IP、OPENRESTY_IP都是脱敏占位符,实际部署时换成自己的值。
先说结论
这次不是“Nuxt 不会取 IP”,而是两套请求路径走了不同的链路:
刷新页面:
浏览器 -> WAF -> OpenResty -> Nginx -> Nuxt SSR
Nuxt 内部再请求 /api/catalog-access
来源是 127.0.0.1,天然被信任
首页搜索跳转:
浏览器直接请求 /api/catalog-access
来源是 Docker 网关
如果网关不在可信代理列表里,Nuxt 就不认 X-Real-IP所以才会出现“刷新正常、点进去不正常”的诡异现象。
部署结构长什么样
实际链路是:
浏览器
↓
WAF
↓
OpenResty
↓
Nginx
↓
Docker 网关
↓
Nuxt 容器 :3000Nuxt 跑在 1Panel 的 Node.js 应用里,端口映射大概是:
0.0.0.0:3000 -> 容器:3000Nginx 里只写了:
proxy_pass http://127.0.0.1:3000/;但对 Nuxt 容器来说,来访问它的不是“浏览器”,而是 Docker 网关:
event.node.req.socket.remoteAddress拿到的就是 DOCKER_GATEWAY_IP。
这就像你在公司前台收快递:快递员把包裹交给前台,前台再转给你。你看到的是前台,不是寄件人。真实寄件人信息只能靠前台登记后传进来。
为什么刷新正常,点进去不正常
这是这次问题里最容易误判的地方。
刷新页面
浏览器请求 HTML,Nuxt 在服务端渲染页面。页面代码会从当前请求里拿代理头:
useRequestHeaders(['x-real-ip', 'x-forwarded-for'])然后请求自己的 server API:
$fetch('/api/catalog-access', {
headers: catalogApiHeaders
})这个内部请求的来源是:
127.0.0.1Nuxt 的代码里本来就信任回环地址:
isLocalProxy('127.0.0.1') === true所以 SSR 内部请求会读取 X-Real-IP,刷新时一切正常。
从首页搜索跳转
首页搜索按钮执行的是客户端路由跳转:
router.push({
path: '/catalog',
query: { q: keyword }
})浏览器不会重新请求 HTML。
进入 /catalog 后,页面里的:
$fetch('/api/catalog-access')是浏览器直接发出去的 HTTP 请求,不是 Nuxt 服务端内部调用。
这次请求的来源是:
DOCKER_GATEWAY_IP如果这个地址不在 Nuxt 的可信代理列表里,Nuxt 就会觉得:
这个请求不是可信代理发来的,我不能相信它带的
X-Real-IP。
于是它返回 socket IP,也就是 Docker 网关。
Nuxt 为什么要这么“矫情”
因为 X-Real-IP 和 X-Forwarded-For 是客户端可以随便伪造的。
如果 Nuxt 无条件相信这两个头,任何人只要加一个:
X-Real-IP: 1.2.3.4就能伪装成校内 IP,绕过访问限制。
所以项目里的逻辑是:
const trustProxy =
config.catalogTrustProxy === true
|| config.catalogTrustProxy === 'true'
const hasProxyHeaders = Boolean(
getRequestHeader(event, 'x-real-ip')
|| getRequestHeader(event, 'x-forwarded-for')
)
const socketIp = getRequestIP(event, { xForwardedFor: false })
const trustedProxies = String(
config.catalogTrustedProxies || '127.0.0.1/32,::1/128'
)
.split(',')
.map(item => item.trim())
.filter(Boolean)
const trustedProxy =
isLocalProxy(socketIp)
|| trustedProxies.some(cidr => matchesCidr(socketIp, cidr))
const trustHeaders =
trustProxy
|| trustedProxy
|| (!socketIp && hasProxyHeaders)
const clientIp = getClientIp(event, trustHeaders, socketIp)而 getClientIp 的逻辑也很简单:
if (trustHeaders) {
const realIp = getRequestHeader(event, 'x-real-ip')
if (realIp) return realIp.split(',')[0].trim()
const forwarded = getRequestHeader(event, 'x-forwarded-for')
if (forwarded) return forwarded.split(',')[0].trim()
}
return socketIp翻译成人话:
先看请求是不是可信代理发来的
↓
是:读 X-Real-IP / X-Forwarded-For
↓
不是:老老实实返回 socket IP这套逻辑本身没问题,问题是我们没有把 Docker 网关加进可信列表。
顺带说一句:trustProxy 是历史遗留的总开关,打开它相当于“谁来都信”。生产环境不建议用它,老老实实维护可信代理 IP 更稳。
修复:三处要一起改
1. Nuxt:告诉它谁可以代表用户
在 nuxt.config.js 里增加运行时配置:
runtimeConfig: {
catalogTrustedProxies: process.env.NUXT_CATALOG_TRUSTED_PROXIES
|| '127.0.0.1/32,::1/128'
}然后在 1Panel 或 Docker Compose 里加环境变量:
NUXT_CATALOG_TRUSTED_PROXIES=DOCKER_GATEWAY_IP/32,127.0.0.1/32,::1/128这里的 DOCKER_GATEWAY_IP 是 Nuxt 容器实际看到的来源,不是 WAF 的地址。
如果 Nuxt 还有一条“直连 WAF”的访问路径,才需要把 WAF 地址也加进去。正常经过 Nginx 的情况下,只填 Nuxt 的直接上游就够了。
改完环境变量要重启容器,不然进程还在用旧配置。
2. Nginx:把真实 IP 接过来
如果 Nginx 直接接收 WAF 的请求:
server {
listen 8104 default_server;
server_name _;
set_real_ip_from WAF_IP/32;
real_ip_header X-Real-IP;
location / {
proxy_pass http://127.0.0.1:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}如果链路是:
WAF -> OpenResty -> Nginx那 Nginx 的直接上游是 OpenResty,就应该信任 OpenResty:
set_real_ip_from OPENRESTY_IP/32;
real_ip_header X-Real-IP;OpenResty 再去信任 WAF:
# OpenResty
set_real_ip_from WAF_IP/32;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;这里有个很朴素的原则:
每一层只信任自己的直接上游。
不要因为知道 WAF 的地址,就把它塞进 Nginx 的 set_real_ip_from。中间隔着 OpenResty,Nginx 根本不会信任这个地址。
3. Docker:别把 3000 端口直接晾在公网
1Panel 里如果是:
3000 -> 3000意味着宿主机所有网卡都能访问 Nuxt。
而 Nuxt 现在信任 Docker 网关。外部请求如果绕过 Nginx 直接访问 3000,再自己伪造一个 X-Real-IP,就可能把访问限制绕过去。
建议改成:
127.0.0.1:3000 -> 3000或者把 Nginx 和 Nuxt 放进同一个 Docker 网络,只让 Nginx 访问,不对外发布 3000。
怎么验证
我当时临时加了一个接口,把几个关键信息都打出来:
// server/api/ip-check.get.ts
import {
defineEventHandler,
getRequestHeader,
getRequestIP,
setResponseHeader
} from 'h3'
export default defineEventHandler((event) => {
setResponseHeader(event, 'Cache-Control', 'no-store')
return {
socketIP: event.node.req.socket.remoteAddress,
realIP: getRequestHeader(event, 'x-real-ip'),
forwardedFor: getRequestHeader(event, 'x-forwarded-for'),
clientIP: getRequestIP(event, { xForwardedFor: true })
}
})访问:
https://library.example.com/api/ip-check如果看到:
{
"socketIP": "::ffff:DOCKER_GATEWAY_IP",
"realIP": "真实用户IP",
"forwardedFor": "真实用户IP"
}说明 Nginx 传对了,Nuxt 也能拿到。
socketIP是 Docker 网关,正常;realIP才是 Nginx 传来的真实用户 IP;clientIP用的是xForwardedFor: true,主要解析X-Forwarded-For。如果上游只传了X-Real-IP,它可能还是 socket IP,以realIP为准。
我还加过一个临时 middleware,把每次 /api/ 请求的这几个值打到日志里。对比首页跳转和刷新,就能很直观看到:
首页搜索跳转 -> socketIP DOCKER_GATEWAY_IP
刷新页面 -> socketIP 127.0.0.1这两个临时脚本只是“观测工具”,不参与真实 IP 计算。验证完删掉,功能照常。
几个坑
1. 把“刷新正常”当成配置生效
SSR 内部请求来源是 127.0.0.1,默认就会被信任。
所以刷新正常,不能证明 NUXT_CATALOG_TRUSTED_PROXIES 生效。必须用客户端请求验证。
2. 只改 Nginx,不改 Nuxt
Nginx 传了 X-Real-IP,Nuxt 不信任来源,还是会返回 socket IP。
3. 只改 Nuxt,不改 Nginx
Nuxt 信任代理,但请求头里没有真实 IP,也没用。
4. 用 $proxy_add_x_forwarded_for 保留客户端传的值
推荐:
proxy_set_header X-Forwarded-For $remote_addr;不推荐:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;因为后者会把客户端传来的 X-Forwarded-For 一起带上。如果上游没有覆盖,客户端可以伪造第一个 IP。
5. 页面显示旧值
如果接口已经返回真实 IP,但页面还是旧值,可能是 useAsyncData 缓存了旧数据。可以临时禁用缓存:
const { data: access } = await useAsyncData(
'catalog-access',
() => $fetch('/api/catalog-access', { headers: catalogApiHeaders }),
{
default: () => ({ ip: '', allowed: false }),
getCachedData: () => undefined
}
)不过这次的主要原因不是缓存,而是代理链少了真实 IP。
最后
这个问题看起来是“Nuxt 获取 IP 有问题”,实际上是一道很典型的代理链题:
SSR 请求和浏览器请求不是同一条路
每一层代理只信任自己的直接上游
Nuxt 只相信明确配置过的代理地址把这三件事想清楚,配置就不容易写错。
最后我留了一份检查清单,下次再遇到类似问题,按这个顺序看就行:
Nginx 的
$remote_addr是真实用户 IP;Nginx 设置了
X-Real-IP $remote_addr;Nginx 使用
X-Forwarded-For $remote_addr;Nuxt 的
NUXT_CATALOG_TRUSTED_PROXIES包含 Docker 网关或 Nginx 容器地址;环境变量确实进入容器;
首页搜索跳转和 F5 刷新都显示真实 IP;
/api/catalog-access返回真实 IP;3000 端口没有直接暴露公网;
临时
ip-check/ip-trace已删除或加了访问控制。