
Поддерживаемый форк node-ip, в котором исправлена непатченная уязвимость SSRF (CVE-2024-29415)
Поддерживаемый форк ip — утилиты для работы с IP-адресами в Node.js — с исправленной уязвимостью SSRF, оставшейся без патча.
Оригинальный пакет имеет ~38 млн загрузок в месяц, не выпускался с февраля 2024 года и содержит уязвимость высокой степени опасности (CVE-2024-29415 / GHSA-2p57-rm9w-gvfp), которая затрагивает каждую опубликованную версию и приводит к сбою npm audit при каждой установке. Этот форк устраняет первопричину, а не латает регулярные выражения.
npm install @bybrave/ip2
// CommonJS — drop-in
const ip = require('@bybrave/ip2');
// ESM — named imports
import { isPrivate, isPublic, cidrSubnet } from '@bybrave/ip2';
// TypeScript types built in
Оригинальные isPrivate() / isPublic() / isLoopback() применяли регулярные выражения к исходной строке. Любое написание адреса, которое не предусматривали регулярные выражения — восьмеричные или шестнадцатеричные октеты, сокращённый IPv4, несжатый IPv6 — молча классифицировалось как публичное:
// original [email protected] — all of these are loopback/private, all reported PUBLIC:
ip.isPublic('127.1') // true 💥
ip.isPublic('012.1.2.3') // true 💥 (octal 10.1.2.3)
ip.isPublic('01200034567') // true 💥 (integer 10.0.14.119)
ip.isPublic('000:0:0000::01') // true 💥 (that's ::1)
Если эти ответы используются для защиты исходящих запросов, это дыра SSRF (CVE-2024-29415, неполное исправление CVE-2023-42282).
Этот форк:
net.isIP, плюс идентификаторы зон IPv6). Классификация выполняется по байтам, а не по строкам.127.1, 0x7f.1, 0177.0.0.1, 2130706433) выбрасывают исключение вместо угадывания. Отказ в сторону закрытия, а не открытия. Если нужно принимать устаревшие нотации, сначала нормализуйте их явно: ip.fromLong(ip.normalizeToLong(addr)).isPrivate() теперь означает «недоступен глобально» — та же конвенция, что и в Python ipaddress. Это добавляет CGNAT (100.64/10), TEST-NET, бенчмаркинг, multicast, зарезервированные, ULA, диапазоны документации и многое другое — см. таблицу ниже.::ffff:a.b.c.d (v4-mapped), 64:ff9b::a.b.c.d (NAT64) и 2002:xxxx:xxxx:: (6to4) являются приватными/публичными в соответствии с IPv4-адресом внутри них.// @bybrave/ip2
ip.isPublic('127.1') // throws Error: Invalid ip address: 127.1
ip.isPrivate('000:0:0000::01') // true (parsed as ::1)
ip.isLoopback('::fFFf:127.0.0.1')// true
ip.isPublic('64:ff9b::8.8.8.8') // true (NAT64 of a public address)
ip.isPrivate('100.64.0.1') // true (CGNAT — original said public)
[email protected]| Исправлено | Исходная проблема |
|---|---|
| CVE-2024-29415 / GHSA-2p57-rm9w-gvfp — SSRF через неканонические адреса | #136, #150, #153, #158 |
isPrivate() пропускает CGNAT, TEST-NET, бенчмаркинг, multicast, зарезервированные, диапазоны документации | — |
cidrSubnet(...).contains() возвращает false для IPv4-mapped IPv6 адресов | #104 |
isV6Format() возвращает true для IPv4-адресов | #67 |
isV4Format() принимает октеты ≥ 256 | #105 |
address('public') возвращал приватные адреса и наоборот (фильтр был инвертирован) | — |
loopback('ipv6') возвращал fe80::1, который является link-local, а не loopback → теперь ::1 | — |
cidr()/cidrSubnet() молча выдавали мусор для недопустимых длин префикса (/33, /x) → теперь выбрасывают исключение | — |
address(name) падал с TypeError для неизвестных имён интерфейсов → теперь возвращает undefined | — |
cidrSubnet()/subnet() давали неверные результаты для IPv6 (/48 содержал соседний /48) → настоящая математика подсетей IPv6 | #89, #110 |
Сборка для браузера падала на os (webpack 5) → поле browser перенаправляет его | #127, #132 |
Встроенные типы TypeScript (не нужен @types/ip), именованные экспорты ESM | — |
- const ip = require('ip');
+ const ip = require('@bybrave/ip2');
Поверхность API та же; различия в поведении — это исправления безопасности и корректности — см. критические изменения ниже перед обновлением.
Версия 3.0.0, потому что строгость и есть исправление — если вы передавали этой библиотеке неканонические строки, v2 давала вам неверные ответы, а не совместимые:
isPrivate / isPublic / isLoopback / toBuffer / toLong выбрасывают исключение при некорректном или неканоническом вводе (восьмеричные/шестнадцатеричные октеты, 127.1, целочисленные строки). Используйте normalizeToLong() сначала, если необходимо принимать устаревшие нотации.isLoopback('fe80::1') теперь false (link-local ≠ loopback; по-прежнему isPrivate). isLoopback('::') теперь false (unspecified ≠ loopback; по-прежнему isPrivate).loopback('ipv6') возвращает '::1' вместо 'fe80::1'.isPrivate() возвращает true для большего числа диапазонов (CGNAT, TEST-NET, multicast, …) — теперь это означает «недоступен глобально».address('public') теперь действительно возвращает публичные адреса. Если вы полагались на инвертированное поведение для получения LAN-адреса, используйте address('private') — именно это оно вам и давало.Всё, что экспортирует оригинал, плюс isValid():
ip.address('public', 'ipv6') // address of a network interface
ip.isPrivate('10.0.0.1') // true — not globally reachable
ip.isPublic('8.8.8.8') // true
ip.isLoopback('127.8.8.8') // true
ip.isValid('300.1.2.3') // false (never throws)
ip.isV4Format('192.168.0.1') // true
ip.isV6Format('2001:db8::1') // true
ip.isEqual('::ffff:7f00:1', '127.0.0.1') // true
ip.toBuffer('127.0.0.1') // Buffer([127, 0, 0, 1])
ip.toString(buf, offset, length) // '127.0.0.1'
ip.toLong('127.0.0.1') // 2130706433
ip.fromLong(2130706433) // '127.0.0.1'
ip.normalizeToLong('127.1') // 2130706433 (lenient, -1 on error)
ip.fromPrefixLen(24) // '255.255.255.0'
ip.mask('192.168.1.134', '255.255.255.0') // '192.168.1.0'
ip.cidr('192.168.1.134/26') // '192.168.1.128'
ip.not('255.255.255.0') // '0.0.0.255'
ip.or('0.0.0.255', '192.168.1.10') // '192.168.1.255'