shouldBypassProxy.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. const LOOPBACK_HOSTNAMES = new Set(['localhost', '0.0.0.0']);
  2. const isIPv4Loopback = (host) => {
  3. const parts = host.split('.');
  4. if (parts.length !== 4) return false;
  5. if (parts[0] !== '127') return false;
  6. return parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255);
  7. };
  8. /**
  9. * Canonicalize an IPv4 address written in shorthand, octal, or hex form into
  10. * dotted-decimal. IPv6 addresses and non-IP strings are returned unchanged so
  11. * the existing IPv4-mapped IPv6 unmap path and the isLoopback path can still
  12. * see them.
  13. *
  14. * Shorthand expansion mirrors Node's URL parser: literal parts fill from the
  15. * left, the final part fills the remaining octets from the right with
  16. * zero-padding on the left.
  17. * 127.1 -> 127.0.0.1
  18. * 127.0.1 -> 127.0.0.1
  19. * 1.2.3 -> 1.2.0.3
  20. *
  21. * Each octet is parsed with an explicit base: 16 for `0x`/`0X` prefix, 8 for
  22. * zero-prefixed multi-digit all-`0-7` parts, 10 otherwise. Zero-prefixed
  23. * decimal-looking parts that contain `8` or `9` are rejected to match Node's
  24. * URL parser, and the comparison layer falls through to non-bypass if either
  25. * side rejects the form (fail-safe).
  26. *
  27. * Returns the input unchanged on any parse failure, out-of-range octet, or
  28. * unusual shape (1-part, 5+ parts) so the comparison layer fails closed.
  29. */
  30. const parseIPv4Octet = (text) => {
  31. if (/^0[xX][0-9a-fA-F]+$/.test(text)) {
  32. const n = parseInt(text.slice(2), 16);
  33. return Number.isFinite(n) ? n : null;
  34. }
  35. if (text.length > 1 && /^0[0-7]+$/.test(text)) {
  36. const n = parseInt(text, 8);
  37. return Number.isFinite(n) ? n : null;
  38. }
  39. if (text.length > 1 && /^0[0-9]+$/.test(text)) {
  40. return null;
  41. }
  42. if (/^[0-9]+$/.test(text)) {
  43. const n = parseInt(text, 10);
  44. return Number.isFinite(n) ? n : null;
  45. }
  46. return null;
  47. };
  48. const normalizeIPAddress = (host) => {
  49. if (typeof host !== 'string' || !host || host.indexOf(':') !== -1) {
  50. return host;
  51. }
  52. let h = host;
  53. if (h.charAt(0) === '[' && h.charAt(h.length - 1) === ']') {
  54. h = h.slice(1, -1);
  55. }
  56. h = h.replace(/\.+$/, '');
  57. // Allowed characters for any IPv4 shape: digits, dot, 'x', 'X', hex digits.
  58. if (!/^[0-9.xXa-fA-F]+$/.test(h)) return host;
  59. const parts = h.split('.');
  60. // No part may be empty (e.g. "127..0.1" or "127.0.0."). Trailing dots are
  61. // already stripped above; this guards against the empty-middle case.
  62. if (parts.some((p) => p === '')) return host;
  63. if (parts.length === 4) {
  64. // Full IPv4 form: each part is an octet.
  65. const octets = parts.map(parseIPv4Octet);
  66. if (octets.some((n) => n === null || n < 0 || n > 255)) return host;
  67. return octets.join('.');
  68. }
  69. if (parts.length > 4) {
  70. return host;
  71. }
  72. // Shorthand: 1..3 parts. Node's URL parser treats a 1-part input as a 32-bit
  73. // integer split into octets, which has surprising semantics (e.g. "127" ->
  74. // "0.0.0.127"). Reject 1-part inputs to keep the helper predictable: the
  75. // fail-safe returns the input unchanged and the comparison layer falls
  76. // through to non-bypass.
  77. if (parts.length === 1) return host;
  78. // 2..3 parts: literal parts fill from the left, tail fills remaining octets
  79. // from the right with zero-padding.
  80. const literalOctets = parts.slice(0, -1);
  81. const tail = parts[parts.length - 1];
  82. const tailSlots = 4 - literalOctets.length;
  83. // Tail is parsed as a full IPv4 number (hex/octal/decimal) and packed
  84. // low-byte-right into the remaining octets, matching Node's URL parser.
  85. // e.g. 127.65535 (tail 0xFFFF into 3 slots) -> 127.0.255.255;
  86. // 127.0x00ff (tail 0xFF into 3 slots) -> 127.0.0.255;
  87. // 127.0.65535 (tail 0xFFFF into 2 slots) -> 127.0.255.255.
  88. const tailValue = parseIPv4Octet(tail);
  89. if (tailValue === null) return host;
  90. const maxTail = (1 << (8 * tailSlots)) - 1;
  91. if (tailValue < 0 || tailValue > maxTail) return host;
  92. const tailOctets = new Array(tailSlots).fill(0);
  93. for (let i = tailSlots - 1, v = tailValue; i >= 0; i--, v >>= 8) {
  94. tailOctets[i] = v & 0xff;
  95. }
  96. const literal = literalOctets.map(parseIPv4Octet);
  97. if (literal.some((n) => n === null || n < 0 || n > 255)) return host;
  98. return [...literal, ...tailOctets].join('.');
  99. };
  100. const isIPv6ZeroGroup = (group) => /^0{1,4}$/.test(group);
  101. // The unspecified address (IPv4 0.0.0.0 / IPv6 ::) resolves to the local host
  102. // for outbound connections, so treat it as loopback-equivalent for NO_PROXY
  103. // matching. 0.0.0.0 is covered by LOOPBACK_HOSTNAMES; this handles compressed
  104. // and full IPv6 all-zero forms so both families bypass symmetrically.
  105. const isIPv6Unspecified = (host) => {
  106. if (host === '::') return true;
  107. const compressionIndex = host.indexOf('::');
  108. if (compressionIndex !== -1) {
  109. if (compressionIndex !== host.lastIndexOf('::')) return false;
  110. const left = host.slice(0, compressionIndex);
  111. const right = host.slice(compressionIndex + 2);
  112. const leftGroups = left ? left.split(':') : [];
  113. const rightGroups = right ? right.split(':') : [];
  114. const explicitGroups = leftGroups.length + rightGroups.length;
  115. return (
  116. explicitGroups < 8 &&
  117. leftGroups.every(isIPv6ZeroGroup) &&
  118. rightGroups.every(isIPv6ZeroGroup)
  119. );
  120. }
  121. const groups = host.split(':');
  122. return groups.length === 8 && groups.every(isIPv6ZeroGroup);
  123. };
  124. const isIPv6Loopback = (host) => {
  125. // Collapse all-zero groups: any form of ::1 / 0:0:...:0:1
  126. // First, strip any leading "::" by normalising with Set lookup of common forms,
  127. // then fall back to structural check.
  128. if (host === '::1') return true;
  129. // Check IPv4-mapped IPv6 loopback: ::ffff:<v4-loopback> or ::ffff:<hex-v4-loopback>
  130. // Node's URL parser normalises ::ffff:127.0.0.1 → ::ffff:7f00:1
  131. const v4MappedDotted = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
  132. if (v4MappedDotted) return isIPv4Loopback(v4MappedDotted[1]);
  133. const v4MappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
  134. if (v4MappedHex) {
  135. const high = parseInt(v4MappedHex[1], 16);
  136. // High 16 bits must start with 127 (0x7f) — i.e. 0x7f00..0x7fff
  137. return high >= 0x7f00 && high <= 0x7fff;
  138. }
  139. // Full-form ::1 variants: any number of zero groups followed by trailing 1
  140. // e.g. 0:0:0:0:0:0:0:1, 0000:...:0001
  141. const groups = host.split(':');
  142. if (groups.length === 8) {
  143. for (let i = 0; i < 7; i++) {
  144. if (!/^0+$/.test(groups[i])) return false;
  145. }
  146. return /^0*1$/.test(groups[7]);
  147. }
  148. return false;
  149. };
  150. const isLoopback = (host) => {
  151. if (!host) return false;
  152. if (LOOPBACK_HOSTNAMES.has(host)) return true;
  153. if (isIPv4Loopback(host)) return true;
  154. if (isIPv6Unspecified(host)) return true;
  155. return isIPv6Loopback(host);
  156. };
  157. const DEFAULT_PORTS = {
  158. http: 80,
  159. https: 443,
  160. ws: 80,
  161. wss: 443,
  162. ftp: 21,
  163. };
  164. const parseNoProxyEntry = (entry) => {
  165. let entryHost = entry;
  166. let entryPort = 0;
  167. if (entryHost.charAt(0) === '[') {
  168. const bracketIndex = entryHost.indexOf(']');
  169. if (bracketIndex !== -1) {
  170. const host = entryHost.slice(1, bracketIndex);
  171. const rest = entryHost.slice(bracketIndex + 1);
  172. if (rest.charAt(0) === ':' && /^\d+$/.test(rest.slice(1))) {
  173. entryPort = Number.parseInt(rest.slice(1), 10);
  174. }
  175. return [host, entryPort];
  176. }
  177. }
  178. const firstColon = entryHost.indexOf(':');
  179. const lastColon = entryHost.lastIndexOf(':');
  180. if (
  181. firstColon !== -1 &&
  182. firstColon === lastColon &&
  183. /^\d+$/.test(entryHost.slice(lastColon + 1))
  184. ) {
  185. entryPort = Number.parseInt(entryHost.slice(lastColon + 1), 10);
  186. entryHost = entryHost.slice(0, lastColon);
  187. }
  188. return [entryHost, entryPort];
  189. };
  190. // Convert IPv4-mapped IPv6 (::ffff:0:0/96 prefix) to IPv4 dotted form so both
  191. // sides of a NO_PROXY comparison see the same canonical address. Without this,
  192. // `NO_PROXY=192.168.1.5` would not match a request to `http://[::ffff:192.168.1.5]/`
  193. // (Node's URL parser normalises that to `[::ffff:c0a8:105]`), and vice-versa,
  194. // allowing the proxy-bypass policy to be circumvented by using the alternate
  195. // representation. Returns the input unchanged when not IPv4-mapped.
  196. const IPV4_MAPPED_DOTTED_RE = /^(?:::|(?:0{1,4}:){1,4}:|(?:0{1,4}:){5})ffff:(\d+\.\d+\.\d+\.\d+)$/i;
  197. const IPV4_MAPPED_HEX_RE = /^(?:::|(?:0{1,4}:){1,4}:|(?:0{1,4}:){5})ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;
  198. const unmapIPv4MappedIPv6 = (host) => {
  199. if (typeof host !== 'string' || host.indexOf(':') === -1) return host;
  200. const dotted = host.match(IPV4_MAPPED_DOTTED_RE);
  201. if (dotted) return dotted[1];
  202. const hex = host.match(IPV4_MAPPED_HEX_RE);
  203. if (hex) {
  204. const high = parseInt(hex[1], 16);
  205. const low = parseInt(hex[2], 16);
  206. return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
  207. }
  208. return host;
  209. };
  210. const normalizeNoProxyHost = (hostname) => {
  211. if (!hostname) {
  212. return hostname;
  213. }
  214. if (hostname.charAt(0) === '[' && hostname.charAt(hostname.length - 1) === ']') {
  215. hostname = hostname.slice(1, -1);
  216. }
  217. const trimmed = hostname.replace(/\.+$/, '');
  218. // IPv4 shorthand/octal/hex → dotted-decimal; helper is a no-op for inputs
  219. // containing ':' (IPv6 and IPv4-mapped IPv6) so we fall through to unmap.
  220. const ipv4 = normalizeIPAddress(trimmed);
  221. if (ipv4 !== trimmed) {
  222. return ipv4;
  223. }
  224. return unmapIPv4MappedIPv6(trimmed);
  225. };
  226. export default function shouldBypassProxy(location) {
  227. let parsed;
  228. try {
  229. parsed = new URL(location);
  230. } catch (_err) {
  231. return false;
  232. }
  233. const noProxy = (process.env.no_proxy || process.env.NO_PROXY || '').toLowerCase();
  234. if (!noProxy) {
  235. return false;
  236. }
  237. if (noProxy === '*') {
  238. return true;
  239. }
  240. const port =
  241. Number.parseInt(parsed.port, 10) || DEFAULT_PORTS[parsed.protocol.split(':', 1)[0]] || 0;
  242. const hostname = normalizeNoProxyHost(parsed.hostname.toLowerCase());
  243. return noProxy.split(/[\s,]+/).some((entry) => {
  244. if (!entry) {
  245. return false;
  246. }
  247. if (entry === '*') {
  248. return true;
  249. }
  250. let [entryHost, entryPort] = parseNoProxyEntry(entry);
  251. entryHost = normalizeNoProxyHost(entryHost);
  252. if (!entryHost) {
  253. return false;
  254. }
  255. if (entryPort && entryPort !== port) {
  256. return false;
  257. }
  258. if (entryHost.charAt(0) === '*') {
  259. entryHost = entryHost.slice(1);
  260. }
  261. if (entryHost.charAt(0) === '.') {
  262. return hostname.endsWith(entryHost);
  263. }
  264. return hostname === entryHost || (isLoopback(hostname) && isLoopback(entryHost));
  265. });
  266. }