@bybrave/ip2
v3.1.0
Published
Maintained fork of ip — IP address utilities with the unpatched SSRF advisory (CVE-2024-29415 / GHSA-2p57-rm9w-gvfp) fixed
Maintainers
Readme
@bybrave/ip2
Maintained fork of ip — IP address utilities for Node.js — with the unpatched SSRF advisory fixed.
The original package has ~38M downloads/month, no release since February 2024, and a HIGH-severity advisory (CVE-2024-29415 / GHSA-2p57-rm9w-gvfp) that affects every published version and makes npm audit fail on every install. This fork fixes the root cause instead of patching regexes.
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 inThe vulnerability, and how it is fixed
The original isPrivate() / isPublic() / isLoopback() ran regexes over the raw input string. Any spelling of an address the regexes didn't anticipate — octal or hex octets, abbreviated IPv4, uncompressed IPv6 — was silently classified as public:
// 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)If those answers guard outgoing requests, that's an SSRF hole (CVE-2024-29415, incomplete fix of CVE-2023-42282).
This fork:
- Parses first, classifies second. Every classification function parses the address into bytes with a strict parser (same grammar as
net.isIP, plus IPv6 zone IDs). Classification runs on bytes, never on strings. - Rejects ambiguous input. Non-canonical spellings (
127.1,0x7f.1,0177.0.0.1,2130706433) throw instead of being guessed at. Fail closed, not open. If you need to accept legacy notations, normalize explicitly first:ip.fromLong(ip.normalizeToLong(addr)). - Covers the full IANA special-purpose registries (IPv4, IPv6), not four regexes.
isPrivate()now means "not globally reachable" — the same convention as Python'sipaddress. That adds CGNAT (100.64/10), TEST-NETs, benchmarking, multicast, reserved, ULA, documentation ranges and more — see the table below. - Classifies transition addresses by their embedded IPv4.
::ffff:a.b.c.d(v4-mapped),64:ff9b::a.b.c.d(NAT64) and2002:xxxx:xxxx::(6to4) are private/public according to the IPv4 address inside them.
// @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)All fixes over [email protected]
| Fixed | Original issue |
|---|---|
| CVE-2024-29415 / GHSA-2p57-rm9w-gvfp — SSRF via non-canonical addresses | #136, #150, #153, #158 |
| isPrivate() misses CGNAT, TEST-NETs, benchmarking, multicast, reserved, documentation ranges | — |
| cidrSubnet(...).contains() returns false for IPv4-mapped IPv6 addresses | #104 |
| isV6Format() returns true for IPv4 addresses | #67 |
| isV4Format() accepts octets ≥ 256 | #105 |
| address('public') returned private addresses and vice versa (filter was inverted) | — |
| loopback('ipv6') returned fe80::1, which is link-local, not loopback → now ::1 | — |
| cidr()/cidrSubnet() silently produced garbage for invalid prefix lengths (/33, /x) → now throw | — |
| address(name) crashed with TypeError for unknown interface names → now returns undefined | — |
| cidrSubnet()/subnet() gave wrong answers for IPv6 (/48 contained a neighbouring /48) → real IPv6 subnet math | #89, #110 |
| Bundling for the browser failed on os (webpack 5) → browser field maps it away | #127, #132 |
| TypeScript types built in (no @types/ip needed), ESM named exports | — |
Migrating from ip
- const ip = require('ip');
+ const ip = require('@bybrave/ip2');The API surface is the same; the behavioural differences are the security and correctness fixes — see the breaking changes below before upgrading.
Breaking changes (v3)
Version 3.0.0 because strictness is the fix — if you fed this library non-canonical strings, v2 was giving you wrong answers, not compatible ones:
isPrivate/isPublic/isLoopback/toBuffer/toLongthrow on malformed or non-canonical input (octal/hex octets,127.1, integer strings). UsenormalizeToLong()first if you must accept legacy notations.isLoopback('fe80::1')is nowfalse(link-local ≠ loopback; stillisPrivate).isLoopback('::')is nowfalse(unspecified ≠ loopback; stillisPrivate).loopback('ipv6')returns'::1'instead of'fe80::1'.isPrivate()returnstruefor more ranges (CGNAT, TEST-NETs, multicast, …) — it now means "not globally reachable".address('public')actually returns public addresses now. If you relied on the inverted behaviour to get a LAN address, useaddress('private')— that's what it was giving you.- Node.js ≥ 18.
API
Everything the original exports, plus 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'
const s = ip.cidrSubnet('192.168.1.134/26')
s.networkAddress // '192.168.1.128'
s.firstAddress // '192.168.1.129'
s.lastAddress // '192.168.1.190'
s.broadcastAddress // '192.168.1.191'
s.subnetMaskLength // 26
s.numHosts // 62
s.contains('::ffff:192.168.1.180') // true (v4-mapped handled)
const s6 = ip.cidrSubnet('2001:db8:f53b::/48')
s6.lastAddress // '2001:db8:f53b:ffff:ffff:ffff:ffff:ffff'
s6.contains('2001:db8:f53a::1') // false
s6.length // 2 ** 80 (approximate above 2 ** 53)IPv6 subnets have no broadcast or reserved host addresses: firstAddress is the network address and broadcastAddress equals lastAddress. ip.subnet(addr, mask) uses IPv6 math when both the address and the mask are IPv6. Plain IPv4 addresses are matched by IPv6 subnets through the IPv4-mapped range (::ffff:0:0/96).
Invalid input throws
isPrivate, isPublic, isLoopback, toBuffer and toLong throw on malformed input instead of returning a guess (#146). When the input is untrusted, check it first or catch:
const isInternal = ip.isValid(host) && ip.isPrivate(host)Browsers and bundlers
The library is Node-first. In a bundle, os is mapped away through the browser field, so address() is unavailable, and everything else works once Buffer is available. With webpack 5, add the polyfill (#127, #132):
// webpack.config.js, after `npm i buffer`
resolve: { fallback: { buffer: require.resolve('buffer/') } }Ranges treated as private (not globally reachable)
IPv4: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10 (CGNAT), 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 (incl. broadcast).
IPv6: ::/96 (unspecified, loopback, v4-compatible), 64:ff9b:1::/48, 100::/64, 2001::/23, 2001:db8::/32, 3fff::/20, 5f00::/16, fc00::/7, fe80::/10, fec0::/10, ff00::/8. Transition ranges ::ffff:0:0/96, 64:ff9b::/96 and 2002::/16 follow their embedded IPv4 address.
Support
If this package saves you time, you can support maintenance:
Bitcoin (BTC): bc1q37557q5jpeaxqydzwvf3jgj7zhnfpn2td3q40q
Credits & license
MIT. Based on node-ip by Fedor Indutny.
