前段时间排查了一个挺有意思的问题。

站点上有个馆藏查询页,页面底部会显示当前访问 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 容器 :3000

Nuxt 跑在 1Panel 的 Node.js 应用里,端口映射大概是:

0.0.0.0:3000 -> 容器:3000

Nginx 里只写了:

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.1

Nuxt 的代码里本来就信任回环地址:

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 已删除或加了访问控制。