File size: 6,069 Bytes
20f83d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// Blocklist for resolved IP addresses (SSRF defence). Callers pass either a
// literal IP (from a DNS answer) or a hostname (pre-resolution short-circuit);
// hostnames that are not IP literals fall through and return false.
//
// IPv6 is decoded numerically so every textual spelling (compressed `::`,
// fully expanded, upper/lowercase, and embedded-IPv4 forms) is classified the
// same way. Embedded-IPv4 encodings (v4-mapped, NAT64, IPv4-compatible, 6to4)
// are decoded to their embedded IPv4 and run through the IPv4 blocklist so a
// private/reserved v4 cannot be smuggled through an IPv6 wrapper.

function isBlockedIpv4(a: number, b: number, c: number): boolean {
  if (a === 0) return true; // 0.0.0.0/8
  if (a === 10) return true; // 10.0.0.0/8
  if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
  if (a === 127) return true; // 127.0.0.0/8 loopback
  if (a === 169 && b === 254) return true; // 169.254.0.0/16 link local
  if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
  if (a === 192 && b === 0 && c === 0) return true; // 192.0.0.0/24 IETF
  if (a === 192 && b === 0 && c === 2) return true; // 192.0.2.0/24 TEST-NET-1
  if (a === 192 && b === 88 && c === 99) return true; // 192.88.99.0/24 deprecated 6to4
  if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
  if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmark
  if (a === 198 && b === 51 && c === 100) return true; // 198.51.100.0/24 TEST-NET-2
  if (a === 203 && b === 0 && c === 113) return true; // 203.0.113.0/24 TEST-NET-3
  if (a >= 224) return true; // multicast + reserved
  return false;
}

function isBlockedDottedIpv4(addr: string): boolean {
  const parts = addr.split('.').map(Number);
  if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) {
    return false;
  }
  const [a, b, c] = parts as [number, number, number, number];
  return isBlockedIpv4(a, b, c);
}

// Decode two consecutive 16-bit IPv6 hextets that carry an embedded IPv4
// (hi = first two octets, lo = last two octets) and run the IPv4 blocklist.
function isBlockedEmbeddedIpv4(hi: number, lo: number): boolean {
  return isBlockedIpv4((hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff);
}

// Expand any textual IPv6 spelling into exactly 8 numeric hextets, or return
// null if the input is not a well-formed IPv6 literal. Handles `::`
// compression and a trailing dotted-quad IPv4 (e.g. ::ffff:1.2.3.4).
function expandIpv6(input: string): number[] | null {
  let s = input;
  const pct = s.indexOf('%'); // strip zone id (fe80::1%eth0)
  if (pct !== -1) s = s.slice(0, pct);
  if (!s.includes(':')) return null;

  // Convert a trailing dotted-quad IPv4 suffix into two hextets.
  const dotMatch = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
  if (dotMatch) {
    const dotted = dotMatch[1];
    if (dotted === undefined) return null; // unreachable (capture group), satisfies TS
    const octets = dotted.split('.').map(Number);
    if (octets.length !== 4 || octets.some(o => !Number.isInteger(o) || o < 0 || o > 255)) return null;
    const [o0, o1, o2, o3] = octets as [number, number, number, number];
    const hextets = [
      (((o0 << 8) | o1) >>> 0).toString(16),
      (((o2 << 8) | o3) >>> 0).toString(16),
    ].join(':');
    s = s.slice(0, s.length - dotted.length) + hextets;
  }

  const halves = s.split('::');
  if (halves.length > 2) return null; // more than one `::` is invalid

  const parseGroups = (part: string): number[] | null => {
    if (part === '') return [];
    const out: number[] = [];
    for (const g of part.split(':')) {
      if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
      out.push(parseInt(g, 16));
    }
    return out;
  };

  if (halves.length === 2) {
    const head = parseGroups(halves[0] ?? '');
    const tail = parseGroups(halves[1] ?? '');
    if (head === null || tail === null) return null;
    const missing = 8 - head.length - tail.length;
    if (missing < 1) return null; // `::` must stand for at least one hextet
    return [...head, ...new Array(missing).fill(0), ...tail];
  }

  const groups = parseGroups(s);
  if (groups === null || groups.length !== 8) return null;
  return groups;
}

export function isBlockedResolvedAddress(address: string): boolean {
  const normalized = address.trim().toLowerCase().replace(/^\[|\]$/g, '');

  // Literal dotted IPv4.
  if (/^\d+\.\d+\.\d+\.\d+$/.test(normalized)) {
    return isBlockedDottedIpv4(normalized);
  }

  const groups = expandIpv6(normalized);
  if (!groups) return false; // not an IP literal (e.g. a hostname) — nothing to block here
  const [g0, g1, g2, , , g5, g6, g7] = groups as
    [number, number, number, number, number, number, number, number];

  // :: (unspecified) and ::1 (loopback).
  const highSevenZero = groups.slice(0, 7).every(part => part === 0);
  if (highSevenZero && (g7 === 0 || g7 === 1)) return true;

  // Reserved / internal IPv6 ranges (mask the leading hextet).
  if ((g0 & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
  if ((g0 & 0xffc0) === 0xfe80) return true; // fe80::/10 link local
  if ((g0 & 0xffc0) === 0xfec0) return true; // fec0::/10 site local (deprecated)
  if ((g0 & 0xff00) === 0xff00) return true; // ff00::/8 multicast
  if (g0 === 0x2001 && g1 === 0x0db8) return true; // 2001:db8::/32 documentation

  // Embedded-IPv4 forms — decode the embedded IPv4 and run the v4 blocklist.
  if (groups.slice(0, 5).every(part => part === 0) && g5 === 0xffff) {
    return isBlockedEmbeddedIpv4(g6, g7); // ::ffff:0:0/96 v4-mapped (dotted + hex)
  }
  if (g0 === 0x64 && g1 === 0xff9b && groups.slice(2, 6).every(part => part === 0)) {
    return isBlockedEmbeddedIpv4(g6, g7); // 64:ff9b::/96 NAT64
  }
  if (groups.slice(0, 6).every(part => part === 0)) {
    return isBlockedEmbeddedIpv4(g6, g7); // ::/96 IPv4-compatible (:: and ::1 handled above)
  }
  if (g0 === 0x2002) {
    return isBlockedEmbeddedIpv4(g1, g2); // 2002::/16 6to4 (embedded v4 in bits 16-48)
  }

  return false;
}