Skip to content

Node.js 怎么获取访客真实 IP 再查归属地?反代场景最容易拿错

先回答最关键的坑:Node 服务挂在 Nginx 或 CDN 后面时,直接读 req.socket.remoteAddress 拿到的是反代服务器的 IP,不是访客的。 拿这个去查归属地,结果全是机房地址,风控、地域统计全废。正确做法是从 X-Forwarded-For 里取真实 IP,但取法有讲究——取错了比不取更危险。

一、为什么反代后面 IP 会"变"

浏览器 → Nginx → Node 应用,这条链路上 Node 看到的 TCP 连接来自 Nginx。访客真实 IP 在 Nginx 转发时被写进了请求头:

nginx
# Nginx 配置里通常长这样
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

$proxy_add_x_forwarded_for 的含义是:把当前直连 Nginx 的那个 IP 追加到 XFF 末尾。所以 XFF 长这样时:

X-Forwarded-For: 用户伪造的, 真实访客IP

注意:XFF 是明文请求头,用户想伪造几个都行。唯一可信的,是从右往左数第一个"不是你自己的代理"的地址——因为最右边那跳是 Nginx 亲眼看到的 TCP 连接,伪造不了。直接取最左(网上教程最常见的写法),等于信任用户输入,风控白名单能被一张 curl -H "X-Forwarded-For: 1.2.3.4" 打穿。

二、方案设计

一个 Express 中间件搞定三件事:

  1. 解析真实 IP:从右往左扫 XFF,跳过可信代理,取第一个外部地址;
  2. 查归属地:请求 https://ip9.com.cn/get?ip=<ip>,免费版免注册,返回 JSON 含国家/省/市/运营商/经纬度等字段,支持 IPv4/IPv6;
  3. 缓存 + 容错:Map 缓存 24 小时,同一 IP 只查一次;接口超时或限流(429)时记日志并放行,不让归属地查询拖垮主流程。

三、Node.js 实现(Express + 原生 fetch,Node 18+)

javascript
const express = require('express');

const app = express();

// ---------- 1. 解析真实 IP:从右往左跳过可信代理 ----------
const TRUSTED_PROXIES = new Set(['127.0.0.1', '::1', '你的Nginx服务器IP']);

function getRealIp(req) {
  const xff = req.headers['x-forwarded-for'];
  if (xff) {
    const parts = xff.split(',').map(s => s.trim());
    for (let i = parts.length - 1; i >= 0; i--) {
      if (!TRUSTED_PROXIES.has(parts[i])) return parts[i];
    }
  }
  return req.socket.remoteAddress?.replace(/^::ffff:/, '') || '';
}

// ---------- 2. 查归属地 + 24h 缓存 ----------
const geoCache = new Map();   // ip -> { geo, expireAt }
const TTL = 24 * 3600 * 1000;

async function lookupGeo(ip) {
  const hit = geoCache.get(ip);
  if (hit && Date.now() < hit.expireAt) return hit.geo;

  let geo = { ip, country: '', prov: '', city: '', isp: '' };
  try {
    const resp = await fetch(`https://ip9.com.cn/get?ip=${ip}`, { signal: AbortSignal.timeout(3000) });
    const j = await resp.json();
    if (j.ret === 200) {
      geo = { ip, country: j.data.country, prov: j.data.prov,
              city: j.data.city, isp: j.data.isp };
    }
  } catch (e) {
    console.warn(`[geo] 查询失败 ${ip}:`, e.message);   // 超时/429 都走这里,放行
  }
  geoCache.set(ip, { geo, expireAt: Date.now() + TTL });
  return geo;
}

// ---------- 3. 中间件:给 req 挂上 geo ----------
app.use(async (req, res, next) => {
  const ip = getRealIp(req);
  req.clientIp = ip;
  // 需要属地的接口才查(用路径前缀控制,省免费额度)
  if (req.path.startsWith('/api/')) {
    req.geo = await lookupGeo(ip);
    console.log(`[access] ${ip} ${req.geo.prov}${req.geo.city} ${req.path}`);
  } else {
    req.geo = null;
  }
  next();
});

// 示例接口:异地登录提示(登录地不在常用地则提醒)
app.get('/api/login', async (req, res) => {
  const { prov, city } = req.geo || {};
  res.json({ code: 0, msg: '登录成功', loginLocation: `${prov || '未知'} ${city || ''}` });
});

app.listen(3000, () => console.log('listening on :3000'));

装依赖、启动、模拟反代请求验证:

bash
npm install express
node app.js
# 模拟 Nginx 转发:真实访客 IP 被 Nginx 追加在 XFF 末尾
curl -H 'X-Forwarded-For: 1.2.3.4, 114.114.114.114' http://127.0.0.1:3000/api/login

114.114.114.114 模拟真实访客出口 IP,1.2.3.4 是用户自己塞进请求头的伪造值。中间件从右往左扫:先看到 114.114.114.114(不在可信名单)→ 它就是真实 IP;伪造的 1.2.3.4 被跳过。日志会打出 [access] 114.114.114.114 江苏南京 /api/login,登录接口返回同样的归属地——伪造值没骗过任何人。

小项目不想引 Express 也行:把中间件那段逻辑抄进 http.createServer 的回调里,req/res 接口是通用的。

四、落地注意事项

  • 可信代理名单必须维护:Nginx IP 写死在 TRUSTED_PROXIES 里。将来加了 CDN 层,要把 CDN 节点 IP 段也加进去,否则 CDN 的出口 IP 会被当成"真实 IP"。名单错了,轻则属地全错,重则风控被绕过。
  • IPv6 记得去前缀:Node 里 IPv6 地址常带 ::ffff: 前缀,上面 replace(/^::ffff:/, '') 就是干这个的——把 IPv4 映射地址还原成 IPv4 再查,格式统一。实测 IP9 接口兼容带前缀的写法,但缓存 key 不统一的话,同一 IP 会以两种形式各查一次、白占额度。
  • 缓存要设上限:Map 无限增长会内存泄漏。生产环境用 lru-cache 包,容量设个 5 万条,或者每天定时清空一次——反正 TTL 也就 24 小时。
  • 免费额度够用吗:免费接口 60 次/分钟/IP,服务器出口只有一个 IP,等于全站每分钟 60 次。上面用路径前缀控制"只有 /api/ 才查",加上缓存,日常够用;真有高并发再考虑付费档,代码不用动,换个地址就行。

总结

反代后面拿真实 IP 的要点就一句:从右往左取第一个非可信代理的地址。IP 取对了,归属地查询才有意义——接口用 https://ip9.com.cn/get?ip=<ip>,免费版免注册、支持 IPv4/IPv6,返回省市、运营商、经纬度,挂个缓存就能给登录提醒、风控、地域日志共用。官网:https://www.ip9.com.cn