-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathip.js
More file actions
275 lines (254 loc) · 12.1 KB
/
Copy pathip.js
File metadata and controls
275 lines (254 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
// ============================================================================
// lib/ip.js — 安全取得「訪客真實 IP」(Cloudflare / 反向代理後方)
// ----------------------------------------------------------------------------
// 為什麼要有這支:
// 舊版直接用 req.headers['x-forwarded-for'],這個 header 使用者可以自己偽造,
// 等於任何人送一行「X-Forwarded-For: 1.2.3.4」就能偽裝成別的 IP,繞過 IP 黑名單
// 與限流(這是資安漏洞,也會讓 VPN/國家判斷查錯 IP)。
//
// 正確做法(Cloudflare 官方 + Express 官方 + adam-p XFF 安全指南):
// 1. 只有在「這條連線真的來自 Cloudflare 邊緣網段」時,才信任 CF-Connecting-IP。
// 2. 否則(有人繞過 CF 直連你的 origin)→ 一律退回 socket 位址,忽略所有 XFF。
// 3. 絕不使用 app.set('trust proxy', true)(= 直接採信 XFF 最左值,可被任意偽造)。
//
// 來源:
// https://developers.cloudflare.com/fundamentals/reference/http-headers/
// https://expressjs.com/en/guide/behind-proxies/
// https://adam-p.ca/blog/2022/03/x-forwarded-for/
// https://www.cloudflare.com/ips/ (可用 https://api.cloudflare.com/client/v4/ips 定期更新)
// ============================================================================
const ipaddr = require('ipaddr.js'); // Express(proxy-addr) 的相依,專案內必定已安裝
const { readBodyLimited } = require('./httpClient');
const CLOUDFLARE_RANGE_MAX_BYTES = 64 * 1024;
// Cloudflare 官方公布的邊緣網段(2024 起穩定的清單)。
// 建議上線後用 refreshCloudflareRanges() 於開機時從官方 API 拉最新版,避免長期寫死過期。
let CLOUDFLARE_CIDRS = [
// IPv4
'173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
'141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
'197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
'104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22',
// IPv6
'2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32',
'2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32',
];
let CF_PARSED = parseCidrList(CLOUDFLARE_CIDRS);
function parseCidrList(list) {
const out = [];
for (const c of list) {
try { out.push(ipaddr.parseCIDR(c)); } catch (e) { /* 跳過格式錯誤的項 */ }
}
return out;
}
function createIpAllowlistMatcher(value) {
const entries = Array.isArray(value)
? value
: String(value || '').split(/[\s,]+/);
const populated = entries.filter(Boolean);
if (populated.length > 64) throw new Error('trusted tunnel proxy allowlist is too large');
const parsed = populated.map(entry => {
try {
if (String(entry).includes('/')) {
const tuple = ipaddr.parseCIDR(String(entry));
const [range, bits] = tuple;
const minimumPrefix = range.kind() === 'ipv4' ? 24 : 64;
if (bits < minimumPrefix) throw new Error('proxy range is too broad');
return tuple;
}
const address = normalizeIp(String(entry));
if (!address) throw new Error('invalid address');
return [address, address.kind() === 'ipv4' ? 32 : 128];
} catch (error) {
// Do not echo the configured value: startup logs should not expose
// internal network topology.
throw new Error('invalid trusted tunnel proxy allowlist');
}
});
return ip => {
const address = normalizeIp(ip);
if (!address) return false;
return parsed.some(([range, bits]) => (
range.kind() === address.kind() && address.match(range, bits)
));
};
}
function parseCloudflareFamily(list, expectedKind) {
if (!Array.isArray(list) || list.length === 0 || list.length > 128) return null;
const cidrs = [...new Set(list.map(value => typeof value === 'string' ? value.trim() : ''))];
if (cidrs.some(value => !value)) return null;
// Cloudflare's published allocations are substantially narrower than
// default/global routes. Leave headroom for future allocations while
// refusing responses broad enough to turn a large Internet region into a
// trusted reverse proxy (current minimums are /13 for v4 and /29 for v6).
const minimumPrefix = expectedKind === 'ipv4' ? 12 : 24;
const parsed = [];
for (const cidr of cidrs) {
try {
const tuple = ipaddr.parseCIDR(cidr);
const [range, bits] = tuple;
if (range.kind() !== expectedKind || bits < minimumPrefix || range.range() !== 'unicast') {
return null;
}
parsed.push(tuple);
} catch (error) {
return null;
}
}
return { cidrs, parsed };
}
// 把 IPv4-mapped IPv6(::ffff:1.2.3.4)正規化成純 IPv4,避免比對與去重失準
function normalizeIp(ip) {
if (!ip) return null;
let addr;
try { addr = ipaddr.parse(ip); } catch (e) { return null; }
if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
addr = addr.toIPv4Address();
}
return addr;
}
function normalizeIpString(ip) {
const a = normalizeIp(ip);
return a ? a.toString() : (ip || null);
}
// 判斷某個 IP 是否屬於 Cloudflare 邊緣網段
function isCloudflareIp(ip) {
const addr = normalizeIp(ip);
if (!addr) return false;
return CF_PARSED.some(([range, bits]) => range.kind() === addr.kind() && addr.match(range, bits));
}
// 判斷是否為本機 / 私有網段(開發測試用;這些一律視為 local、不打外部 IP API)
function isPrivateOrLoopback(ip) {
const addr = normalizeIp(ip);
if (!addr) return false;
const range = addr.range(); // 'loopback' | 'private' | 'linkLocal' | 'uniqueLocal' | 'unicast'...
return ['loopback', 'private', 'linkLocal', 'uniqueLocal', 'reserved'].includes(range);
}
// 精確判斷「本機 loopback」(127.0.0.1 / ::1)。Cloudflare Tunnel 模式下 cloudflared 在本機,
// 以 loopback 連進來,此時才可安全信任它帶的 CF-Connecting-IP。
function isLoopback(ip) {
const addr = normalizeIp(ip);
if (!addr) return false;
return addr.range() === 'loopback';
}
/**
* 取得可信的「訪客真實 IP」。
* @param {import('express').Request} req
* @param {object} [opts]
* @param {boolean} [opts.trustCloudflare=true] 是否在 socket 屬 CF 網段時信任 CF-Connecting-IP
* @param {boolean} [opts.trustTunnelProxy=false] 是否啟用本機/明確 allowlist 的 Tunnel peer
* @param {(ip:string)=>boolean} [opts.isTrustedTunnelProxy] 非 loopback Tunnel peer allowlist matcher
* @returns {string} 正規化後的 IP 字串(永遠回傳字串,取不到時回 socket 位址)
*/
function getRealClientIp(req, opts = {}) {
const trustCloudflare = opts.trustCloudflare !== false;
// Tunnel 模式預設只信 loopback。Docker/LAN 私網不等同 cloudflared 身分,
// 必須由啟動設定提供精確 allowlist,否則同網段任一主機都能偽造 client header。
const trustTunnelProxy = opts.trustTunnelProxy === true;
const socketIp = req.socket ? req.socket.remoteAddress : null;
let explicitlyTrustedTunnelPeer = false;
if (trustTunnelProxy && typeof opts.isTrustedTunnelProxy === 'function') {
try { explicitlyTrustedTunnelPeer = opts.isTrustedTunnelProxy(socketIp) === true; }
catch (e) { explicitlyTrustedTunnelPeer = false; }
}
const socketIsTrusted =
(trustCloudflare && isCloudflareIp(socketIp)) ||
(trustTunnelProxy && (isLoopback(socketIp) || explicitlyTrustedTunnelPeer));
if (socketIsTrusted) {
const cf = req.headers['cf-connecting-ip'];
if (typeof cf === 'string' && ipaddr.isValid(cf.trim())) {
return normalizeIpString(cf.trim());
}
}
// 沒有經過 CF(本機測試、或有人直連 origin 想偽造 header)→ 一律只信 socket 位址
return normalizeIpString(socketIp) || socketIp || '0.0.0.0';
}
/**
* 產生限流用的 key:對 IPv6 收斂到 /64 子網(同一人常擁有整段 IPv6,不收斂會被繞過限流)。
* 這支自己實作,不依賴 express-rate-limit 的 ipKeyGenerator(本專案安裝的 7.5.1 未匯出該函式)。
*/
function maskIpForRateLimit(ip) {
const addr = normalizeIp(ip);
if (!addr) return ip || 'unknown';
if (addr.kind() === 'ipv6') {
const bytes = addr.toByteArray(); // 16 bytes
for (let i = 8; i < 16; i++) bytes[i] = 0; // 保留前 64 bits,其餘歸零 = /64
try { return ipaddr.fromByteArray(bytes).toString() + '/64'; } catch (e) { return addr.toString(); }
}
return addr.toString();
}
/**
* 從 Cloudflare 官方 API 更新邊緣網段(開機時呼叫一次即可)。失敗不影響運作(沿用內建清單)。
*/
async function refreshCloudflareRanges(value = {}) {
const opts = typeof value === 'number' ? { timeoutMs: value } : (value || {});
const requestedTimeout = Number(opts.timeoutMs);
const timeoutMs = Math.min(15000, Math.max(100,
Number.isFinite(requestedTimeout) ? requestedTimeout : 4000));
const fetchFn = typeof opts.fetchFn === 'function' ? opts.fetchFn : globalThis.fetch;
const setTimeoutFn = typeof opts.setTimeoutFn === 'function' ? opts.setTimeoutFn : setTimeout;
const clearTimeoutFn = typeof opts.clearTimeoutFn === 'function' ? opts.clearTimeoutFn : clearTimeout;
const metrics = opts.metrics;
const increment = name => {
try { metrics?.increment?.(name); } catch (e) { /* metrics must never affect IP trust */ }
};
const setGauge = (name, amount) => {
try { metrics?.setGauge?.(name, amount); } catch (e) { /* metrics must never affect IP trust */ }
};
const ctrl = new AbortController();
let timer;
try {
if (typeof fetchFn !== 'function') throw new Error('cloudflare range fetch unavailable');
timer = setTimeoutFn(() => ctrl.abort(), timeoutMs);
timer?.unref?.();
const res = await fetchFn('https://api.cloudflare.com/client/v4/ips', { signal: ctrl.signal });
if (!res || !res.ok) {
const status = Number.isFinite(Number(res?.status)) ? Number(res.status) : 'unknown';
throw new Error(`cloudflare-ip-ranges HTTP ${status}`);
}
const body = await readBodyLimited(res, CLOUDFLARE_RANGE_MAX_BYTES);
const json = JSON.parse(body.toString('utf8'));
if (json && json.success && json.result) {
const v4 = json.result.ipv4_cidrs || [];
const v6 = json.result.ipv6_cidrs || [];
const parsedV4 = parseCloudflareFamily(v4, 'ipv4');
const parsedV6 = parseCloudflareFamily(v6, 'ipv6');
// Replace the known-good built-in list atomically only when the
// complete dual-stack response is global, valid and bounded. This
// rejects partial lists, address-family swaps and default routes.
if (parsedV4 && parsedV6) {
const merged = [...parsedV4.cidrs, ...parsedV6.cidrs];
CLOUDFLARE_CIDRS = merged;
CF_PARSED = [...parsedV4.parsed, ...parsedV6.parsed];
increment('cloudflare_range_refresh_success');
setGauge('cloudflare_range_count', merged.length);
return { ok: true, count: merged.length };
}
}
increment('cloudflare_range_refresh_failure');
return { ok: false, reason: 'unexpected-response' };
} catch (e) {
increment('cloudflare_range_refresh_failure');
return {
ok: false,
reason: String(e?.message || 'cloudflare-range-refresh-failed').slice(0, 160),
};
} finally {
if (timer !== undefined) {
try { clearTimeoutFn(timer); } catch (e) { /* preserve fail-open refresh semantics */ }
}
}
}
function getCloudflareRanges() {
return CLOUDFLARE_CIDRS.slice();
}
module.exports = {
getRealClientIp,
maskIpForRateLimit,
isCloudflareIp,
isPrivateOrLoopback,
isLoopback,
normalizeIpString,
createIpAllowlistMatcher,
refreshCloudflareRanges,
getCloudflareRanges,
};