|
| 1 | +import net from 'node:net'; |
| 2 | + |
| 3 | +function parseIpv4(value: string): number[] | undefined { |
| 4 | + const parts = value.split('.'); |
| 5 | + if (parts.length !== 4) return undefined; |
| 6 | + const bytes = parts.map((part) => { |
| 7 | + if (!/^\d+$/.test(part)) return Number.NaN; |
| 8 | + const value = Number(part); |
| 9 | + return value >= 0 && value <= 255 ? value : Number.NaN; |
| 10 | + }); |
| 11 | + return bytes.some(Number.isNaN) ? undefined : bytes; |
| 12 | +} |
| 13 | + |
| 14 | +function parseIpv6Words(value: string): number[] | undefined { |
| 15 | + let input = value.toLowerCase(); |
| 16 | + const zoneIndex = input.indexOf('%'); |
| 17 | + if (zoneIndex >= 0) input = input.slice(0, zoneIndex); |
| 18 | + |
| 19 | + const lastColon = input.lastIndexOf(':'); |
| 20 | + if (lastColon >= 0 && input.slice(lastColon + 1).includes('.')) { |
| 21 | + const ipv4Tail = parseIpv4(input.slice(lastColon + 1)); |
| 22 | + if (!ipv4Tail) return undefined; |
| 23 | + const firstWord = ((ipv4Tail[0] << 8) | ipv4Tail[1]).toString(16); |
| 24 | + const secondWord = ((ipv4Tail[2] << 8) | ipv4Tail[3]).toString(16); |
| 25 | + input = `${input.slice(0, lastColon)}:${firstWord}:${secondWord}`; |
| 26 | + } |
| 27 | + |
| 28 | + const halves = input.split('::'); |
| 29 | + if (halves.length > 2) return undefined; |
| 30 | + const left = halves[0] ? halves[0].split(':') : []; |
| 31 | + const right = halves.length === 2 && halves[1] ? halves[1].split(':') : []; |
| 32 | + const parseWord = (part: string): number | undefined => { |
| 33 | + if (!/^[0-9a-f]{1,4}$/.test(part)) return undefined; |
| 34 | + return Number.parseInt(part, 16); |
| 35 | + }; |
| 36 | + const words = [...left, ...right].map(parseWord); |
| 37 | + if (words.some((word) => word === undefined)) return undefined; |
| 38 | + const missing = halves.length === 2 ? 8 - words.length : 0; |
| 39 | + if ((halves.length === 1 && words.length !== 8) || missing < 0) { |
| 40 | + return undefined; |
| 41 | + } |
| 42 | + return [ |
| 43 | + ...(left.map(parseWord) as number[]), |
| 44 | + ...Array(missing).fill(0), |
| 45 | + ...(right.map(parseWord) as number[]), |
| 46 | + ]; |
| 47 | +} |
| 48 | + |
| 49 | +function ipv4In( |
| 50 | + bytes: number[], |
| 51 | + first: number, |
| 52 | + secondStart?: number, |
| 53 | + secondEnd?: number |
| 54 | +): boolean { |
| 55 | + if (bytes[0] !== first) return false; |
| 56 | + if (secondStart === undefined) return true; |
| 57 | + return bytes[1] >= secondStart && bytes[1] <= (secondEnd ?? secondStart); |
| 58 | +} |
| 59 | + |
| 60 | +function isPublicIpLiteral(value: string): boolean { |
| 61 | + const ipVersion = net.isIP(value); |
| 62 | + if (ipVersion === 4) { |
| 63 | + const bytes = parseIpv4(value); |
| 64 | + if (!bytes) return false; |
| 65 | + if (ipv4In(bytes, 0)) return false; |
| 66 | + if (ipv4In(bytes, 10)) return false; |
| 67 | + if (ipv4In(bytes, 100, 64, 127)) return false; // CGNAT |
| 68 | + if (ipv4In(bytes, 127)) return false; |
| 69 | + if (ipv4In(bytes, 169, 254)) return false; |
| 70 | + if (ipv4In(bytes, 172, 16, 31)) return false; |
| 71 | + if (ipv4In(bytes, 192, 0, 0)) return false; |
| 72 | + if (ipv4In(bytes, 192, 0, 2)) return false; // TEST-NET-1 |
| 73 | + if (ipv4In(bytes, 192, 88, 99)) return false; // 6to4 relay anycast |
| 74 | + if (ipv4In(bytes, 192, 168)) return false; |
| 75 | + if (ipv4In(bytes, 198, 18, 19)) return false; // benchmark |
| 76 | + if (ipv4In(bytes, 198, 51, 100)) return false; // TEST-NET-2 |
| 77 | + if (ipv4In(bytes, 203, 0, 113)) return false; // TEST-NET-3 |
| 78 | + if (bytes[0] >= 224) return false; |
| 79 | + return true; |
| 80 | + } |
| 81 | + |
| 82 | + if (ipVersion === 6) { |
| 83 | + const words = parseIpv6Words(value); |
| 84 | + if (!words) return false; |
| 85 | + const first = words[0]; |
| 86 | + const isAllZero = words.every((word) => word === 0); |
| 87 | + if (isAllZero) return false; |
| 88 | + if (words.slice(0, 7).every((word) => word === 0) && words[7] === 1) return false; |
| 89 | + if ((first & 0xffc0) === 0xfe80) return false; // link-local |
| 90 | + if ((first & 0xfe00) === 0xfc00) return false; // unique local |
| 91 | + if ((first & 0xff00) === 0xff00) return false; // multicast |
| 92 | + if ((first & 0xfff0) === 0x2001 && words[1] === 0x0db8) return false; // documentation |
| 93 | + if (first === 0x2002) return false; // 6to4 embeds IPv4 |
| 94 | + if (first === 0x64 && words[1] === 0xff9b) return false; // well-known NAT64 |
| 95 | + const isMapped = |
| 96 | + words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff; |
| 97 | + if (isMapped) { |
| 98 | + const bytes = [ |
| 99 | + words[6] >> 8, |
| 100 | + words[6] & 0xff, |
| 101 | + words[7] >> 8, |
| 102 | + words[7] & 0xff, |
| 103 | + ]; |
| 104 | + return isPublicIpLiteral(bytes.join('.')); |
| 105 | + } |
| 106 | + return true; |
| 107 | + } |
| 108 | + |
| 109 | + return false; |
| 110 | +} |
| 111 | + |
| 112 | +/** |
| 113 | + * Returns a trusted client identity only when the hosting edge has replaced |
| 114 | + * X-Forwarded-For with one public address. Multi-hop or private values are |
| 115 | + * client-spoofable at the application boundary, so keyless access fails closed. |
| 116 | + */ |
| 117 | +export function extractSinglePublicClientIp( |
| 118 | + rawForwardedFor: string | string[] | undefined |
| 119 | +): string | undefined { |
| 120 | + const raw = Array.isArray(rawForwardedFor) |
| 121 | + ? rawForwardedFor[0] |
| 122 | + : rawForwardedFor; |
| 123 | + if (typeof raw !== 'string') return undefined; |
| 124 | + |
| 125 | + const parts = raw |
| 126 | + .split(',') |
| 127 | + .map((part) => part.trim()) |
| 128 | + .filter(Boolean); |
| 129 | + if (parts.length !== 1) return undefined; |
| 130 | + |
| 131 | + const candidate = parts[0].replace(/^\[(.*)\]$/, '$1').toLowerCase(); |
| 132 | + return isPublicIpLiteral(candidate) ? candidate : undefined; |
| 133 | +} |
0 commit comments