File size: 3,206 Bytes
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * Strip an IPv4-mapped IPv6 prefix so comparisons work consistently.
 * Node often reports socket addresses as `::ffff:1.2.3.4` behind dual-stack
 * listeners; this returns the bare `1.2.3.4`.
 */
export function normalizeIp(ip: string): string {
  if (!ip) return ip;
  const match = ip.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
  return match ? match[1] : ip;
}

/** Minimal request shape needed for client-IP resolution (framework-agnostic). */
export interface RequestLike {
  ip?: string;
  socket?: { remoteAddress?: string };
  headers: Record<string, string | string[] | undefined>;
}

/**
 * Resolve the real client IP. X-Forwarded-For is client-controllable, so it is only
 * honored when the request actually arrives from a configured trusted proxy. With no
 * trusted proxies, the direct socket address is used (prevents XFF spoofing). Shared by
 * ApiKeyGuard (allowedIps whitelist) and the throttler (per-client rate-limit bucket).
 */
export function resolveClientIp(req: RequestLike, trustedProxies: string[]): string {
  const socketIp = normalizeIp(req.socket?.remoteAddress || req.ip || '');

  if (!trustedProxies || trustedProxies.length === 0) {
    return socketIp;
  }

  const isTrusted = (ip: string): boolean => trustedProxies.some(proxy => ipMatches(ip, proxy));

  // Only trust the forwarded chain if the immediate peer is a trusted proxy.
  if (!isTrusted(socketIp)) {
    return socketIp;
  }

  const forwarded = req.headers['x-forwarded-for'];
  if (!forwarded) {
    return socketIp;
  }

  const hops = (Array.isArray(forwarded) ? forwarded.join(',') : forwarded)
    .split(',')
    .map(hop => normalizeIp(hop.trim()))
    .filter(Boolean);

  // Walk right-to-left and return the first hop that is not a trusted proxy:
  // the closest address the trusted infrastructure actually observed.
  for (let i = hops.length - 1; i >= 0; i--) {
    if (!isTrusted(hops[i])) {
      return hops[i];
    }
  }

  return socketIp;
}

function ipv4ToInt(ip: string): number | null {
  const parts = ip.split('.');
  if (parts.length !== 4) return null;
  let result = 0;
  for (const part of parts) {
    if (!/^\d{1,3}$/.test(part)) return null;
    const octet = Number(part);
    if (octet > 255) return null;
    result = result * 256 + octet;
  }
  return result >>> 0;
}

/**
 * True if `ip` equals or falls within `target`, where `target` is either an
 * exact IP or an IPv4 CIDR (e.g. `172.18.0.0/16`). IPv4-mapped IPv6 inputs are
 * normalized first. Malformed input yields `false` rather than throwing.
 */
export function ipMatches(ip: string, target: string): boolean {
  const candidate = normalizeIp((ip || '').trim());
  const ref = (target || '').trim();

  if (!ref.includes('/')) {
    return normalizeIp(ref) === candidate;
  }

  const [range, bitsRaw] = ref.split('/');
  const bits = Number(bitsRaw);
  if (!Number.isInteger(bits) || bits < 0 || bits > 32) return false;

  const ipInt = ipv4ToInt(candidate);
  const rangeInt = ipv4ToInt(normalizeIp(range));
  if (ipInt === null || rangeInt === null) return false;

  if (bits === 0) return true;
  const mask = (0xffffffff << (32 - bits)) >>> 0;
  return (ipInt & mask) === (rangeInt & mask);
}