Appearance
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 中间件搞定三件事:
- 解析真实 IP:从右往左扫 XFF,跳过可信代理,取第一个外部地址;
- 查归属地:请求
https://ip9.com.cn/get?ip=<ip>,免费版免注册,返回 JSON 含国家/省/市/运营商/经纬度等字段,支持 IPv4/IPv6; - 缓存 + 容错: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/login114.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