diff --git a/package-lock.json b/package-lock.json index 3aa737a61..1b548309d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,6 @@ "domrobot-client": "^3.2.2", "ejs": "^3.1.10", "express": "^4.21.2", - "ipaddr.js": "^2.2.0", "jose": "^5.9.6", "jsdom": "^26.0.0", "jsonwebtoken": "^9.0.2", @@ -5362,14 +5361,6 @@ "version": "1.3.8", "license": "ISC" }, - "node_modules/ipaddr.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", - "engines": { - "node": ">= 10" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "dev": true, diff --git a/package.json b/package.json index dd22fd269..a398b4f6d 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "domrobot-client": "^3.2.2", "ejs": "^3.1.10", "express": "^4.21.2", - "ipaddr.js": "^2.2.0", "jose": "^5.9.6", "jsdom": "^26.0.0", "jsonwebtoken": "^9.0.2", diff --git a/src/directoryserver.js b/src/directoryserver.js index 896245fdb..f64953e2b 100644 --- a/src/directoryserver.js +++ b/src/directoryserver.js @@ -16,7 +16,7 @@ const assert = require('assert'), constants = require('./constants.js'), debug = require('debug')('box:directoryserver'), eventlog = require('./eventlog.js'), - ipaddr = require('ipaddr.js'), + ipaddr = require('./ipaddr.js'), groups = require('./groups.js'), ldap = require('ldapjs'), path = require('path'), @@ -56,7 +56,6 @@ async function validateConfig(config) { for (const line of allowlist.split('\n')) { if (!line || line.startsWith('#')) continue; const rangeOrIP = line.trim(); - // this checks for IPv4 and IPv6 if (!ipaddr.isValid(rangeOrIP) && !ipaddr.isValidCIDR(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `${rangeOrIP} is not a valid IP or range`); gotOne = true; } diff --git a/src/dns.js b/src/dns.js index e99216675..798102b1f 100644 --- a/src/dns.js +++ b/src/dns.js @@ -30,7 +30,7 @@ const apps = require('./apps.js'), dashboard = require('./dashboard.js'), debug = require('debug')('box:dns'), domains = require('./domains.js'), - ipaddr = require('ipaddr.js'), + ipaddr = require('./ipaddr.js'), mail = require('./mail.js'), mailServer = require('./mailserver.js'), network = require('./network.js'), @@ -141,7 +141,7 @@ async function checkDnsRecords(subdomain, domain) { const ipv6Records = await getDnsRecords(subdomain, domain, 'AAAA'); // if empty OR exactly one record with the ip, we don't need to overwrite - if (ipv6Records.length !== 0 && (ipv6Records.length !== 1 || ipaddr.parse(ipv6Records[0]).toRFC5952String() !== ipv6)) return { needsOverwrite: true }; + if (ipv6Records.length !== 0 && (ipv6Records.length !== 1 || !ipaddr.isEqual(ipv6Records[0], ipv6))) return { needsOverwrite: true }; } return { needsOverwrite: false }; // one record exists and in sync diff --git a/src/dns/cloudflare.js b/src/dns/cloudflare.js index 698dfc52d..269384636 100644 --- a/src/dns/cloudflare.js +++ b/src/dns/cloudflare.js @@ -16,7 +16,6 @@ const assert = require('assert'), debug = require('debug')('box:dns/cloudflare'), dig = require('../dig.js'), dns = require('../dns.js'), - ipaddr = require('ipaddr.js'), safe = require('safetydance'), superagent = require('../superagent.js'), waitForDns = require('./waitfordns.js'), @@ -144,10 +143,8 @@ async function upsert(domainObject, location, type, values) { }; if (i >= records.length) { // create a new record - // cloudflare will error if proxied is set for wrong record type or IP if (type === 'A' || type === 'AAAA' || type === 'CNAME') { - const isUnicast = ipaddr.parse(value).range() === 'unicast'; - data.proxied = isUnicast ? !!domainConfig.defaultProxyStatus : false; // only set at install time + data.proxied = !!domainConfig.defaultProxyStatus; // note that cloudflare will error if proxied is set for wrong record type or IP. only set at install time } debug(`upsert: Adding new record fqdn: ${fqdn}, zoneName: ${zoneName} proxied: ${data.proxied}`); diff --git a/src/ipaddr.js b/src/ipaddr.js new file mode 100644 index 000000000..09730ffb5 --- /dev/null +++ b/src/ipaddr.js @@ -0,0 +1,62 @@ +'use strict'; + +exports = module.exports = { + isValid, + isValidCIDR, + isEqual, + includes, +}; + +const assert = require('assert'), + net = require('net'); + +function isValid(ip) { + assert.strictEqual(typeof ip, 'string'); + + const type = net.isIP(ip); + return type === 4 || type === 6; +} + +function isValidCIDR(cidr) { + assert.strictEqual(typeof cidr, 'string'); + + const parts = cidr.split('/'); + if (parts.length !== 2) return false; + + const [ ip, prefixString ] = parts; + const type = net.isIP(ip); + if (type === 0) return false; + + const prefix = Number.parseInt(prefixString, 10); + if (!Number.isInteger(prefix) || prefix < 0 || (prefix > (type === 4 ? 32 : 128)) || String(prefix) !== prefixString) return false; + + return true; +} + +function isEqual(ip1, ip2) { + assert.strictEqual(typeof ip1, 'string'); + assert.strictEqual(typeof ip2, 'string'); + + if (ip1 === ip2) return true; + + const type1 = net.isIP(ip1), type2 = net.isIP(ip2); + if (type1 === 0 || type2 === 0) return false; // otherwise, it will throw invalid socket address below + + // use blocklist to compare since strings may not be in RFC 5952 format + const blockList = new net.BlockList(); + blockList.addAddress(ip1, `ipv${type1}`); + return blockList.check(ip2, `ipv${type2}`); +} + +function includes(cidr, ip) { + assert.strictEqual(typeof cidr, 'string'); + assert.strictEqual(typeof ip, 'string'); + + const type = net.isIP(ip); + const [ subnet, prefix ] = cidr.split('/'); + const subnetType = net.isIP(subnet); + + const blockList = new net.BlockList(); + blockList.addSubnet(subnet, parseInt(prefix, 10), `ipv${subnetType}`); + return blockList.check(ip, `ipv${type}`); +} diff --git a/src/network.js b/src/network.js index df3faf13b..2247a4c15 100644 --- a/src/network.js +++ b/src/network.js @@ -27,7 +27,7 @@ const assert = require('assert'), constants = require('./constants.js'), cron = require('./cron.js'), fs = require('fs'), - ipaddr = require('ipaddr.js'), + ipaddr = require('./ipaddr.js'), path = require('path'), paths = require('./paths.js'), safe = require('safetydance'), @@ -74,20 +74,16 @@ async function setBlocklist(blocklist, auditSource) { assert.strictEqual(typeof blocklist, 'string'); assert.strictEqual(typeof auditSource, 'object'); - const parsedIp = ipaddr.process(auditSource.ip); // will demangle IPv4 mapped IPv6 - let count = 0; for (const line of blocklist.split('\n')) { if (!line || line.startsWith('#')) continue; const rangeOrIP = line.trim(); - // this checks for IPv4 and IPv6 if (!ipaddr.isValid(rangeOrIP) && !ipaddr.isValidCIDR(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `${rangeOrIP} is not a valid IP or range`); if (rangeOrIP.indexOf('/') === -1) { - if (auditSource.ip === rangeOrIP) throw new BoxError(BoxError.BAD_FIELD, `${rangeOrIP} includes client IP. Cannot block yourself`); + if (ipaddr.isEqual(rangeOrIP, auditSource.ip)) throw new BoxError(BoxError.BAD_FIELD, `IP ${rangeOrIP} is the client IP. Cannot block yourself`); } else { - const parsedRange = ipaddr.parseCIDR(rangeOrIP); // returns [addr, range] - if (parsedRange[0].kind() === parsedIp.kind() && parsedIp.match(parsedRange)) throw new BoxError(BoxError.BAD_FIELD, `${rangeOrIP} includes client IP. Cannot block yourself`); + if (ipaddr.includes(rangeOrIP, auditSource.ip)) throw new BoxError(BoxError.BAD_FIELD, `range ${rangeOrIP} includes client IP. Cannot block yourself`); } ++count; } diff --git a/src/reverseproxy.js b/src/reverseproxy.js index 331b21a1e..534cac315 100644 --- a/src/reverseproxy.js +++ b/src/reverseproxy.js @@ -49,7 +49,7 @@ const acme2 = require('./acme2.js'), domains = require('./domains.js'), ejs = require('ejs'), eventlog = require('./eventlog.js'), - ipaddr = require('ipaddr.js'), + ipaddr = require('./ipaddr.js'), fs = require('fs'), Location = require('./location.js'), mailServer = require('./mailserver.js'), @@ -783,7 +783,6 @@ async function setTrustedIps(trustedIps) { for (const line of trustedIps.split('\n')) { if (!line || line.startsWith('#')) continue; const rangeOrIP = line.trim(); - // this checks for IPv4 and IPv6 if (!ipaddr.isValid(rangeOrIP) && !ipaddr.isValidCIDR(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `${rangeOrIP} is not a valid IP or range`); trustedIpsConfig += `set_real_ip_from ${rangeOrIP};\n`; } diff --git a/src/test/ipaddr-test.js b/src/test/ipaddr-test.js new file mode 100644 index 000000000..cdd9f71d0 --- /dev/null +++ b/src/test/ipaddr-test.js @@ -0,0 +1,113 @@ +'use strict'; + +/* global it, describe */ + +const expect = require('expect.js'), + ipaddr = require('../ipaddr.js'); + +describe('ipaddr', function () { + describe('IPv4', function () { + const goodIPv4s = [ '1.2.3.4', '0.235.255.123' ]; + const badIPv4s = [ '1.2.3', '1.2.3.256', '-1.2.3.4', '1e2.5.6.7', 'x.7.8.9', '1..2.4' ]; + + for (const goodIPv4 of goodIPv4s) { + it(`isValid returns true ${goodIPv4}`, () => expect(ipaddr.isValid(goodIPv4)).to.be(true)); + } + + for (const badIPv4 of badIPv4s) { + it(`isValid returns false ${badIPv4}`, () => expect(ipaddr.isValid(badIPv4)).to.be(false)); + } + + const goodCIDRs = [ + '192.168.1.0/24' + ]; + + const badCIDRs = [ + '192.168.1.0/-1', + '192.168.1.0/', + '192.168.1.0/33', + '192.168.1.0/1e2' + ]; + + for (const goodCIDR of goodCIDRs) { + it(`isValidCIDR returns true ${goodCIDR}`, () => expect(ipaddr.isValidCIDR(goodCIDR)).to.be(true)); + } + + for (const badCIDR of badCIDRs) { + it(`isValidCIDR returns false ${badCIDR}`, () => expect(ipaddr.isValidCIDR(badCIDR)).to.be(false)); + } + + it('isEqual', function () { + expect(ipaddr.isEqual('1.2.3.4', '1.2.3.4')).to.be(true); + expect(ipaddr.isEqual('1.2.3.4', '1.2.3.5')).to.be(false); + expect(ipaddr.isEqual('1.2.3.4', '1.2.3.')).to.be(false); + }); + + it('includes', function () { + expect(ipaddr.includes('1.2.3.0/8', '1.2.3.4')).to.be(true); + expect(ipaddr.includes('1.2.0.0/16', '1.3.0.0')).to.be(false); + }); + }); + + describe('IPv6', function () { + const goodIPv6s = [ + '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + '::1', + '2001:db8::ff00:42:8329', + '::ffff:1.2.3.4', + '2001:db8:85a3::8a2e:370:7334', + '2001:0db8:85a3::', + '2000::', + '2002:c0a8:101::42', + '::a:b' + ]; + const badIPv6s = [ + '2001:db8::85a3::8a2e:370:7334', // too many :: + '2001:db8::85a3:8g2e:370:7334', // invalid hex + '12345::1', // segment too long + '::a:b:c:d:e:f:f:f:f', // too many segments + ':192:168:0:1', + ]; + + for (const goodIPv6 of goodIPv6s) { + it(`isValid returns true ${goodIPv6}`, () => expect(ipaddr.isValid(goodIPv6)).to.be(true)); + } + + for (const badIPv6 of badIPv6s) { + it(`isValid returns false ${badIPv6}`, () => expect(ipaddr.isValid(badIPv6)).to.be(false)); + } + + const goodCIDRs = [ + '2001:db8::/32', + '::/128' + ]; + + const badCIDRs = [ + '2001:db8::/129', + '::a/abc' + ]; + + for (const goodCIDR of goodCIDRs) { + it(`isValidCIDR returns true ${goodCIDR}`, () => expect(ipaddr.isValidCIDR(goodCIDR)).to.be(true)); + } + + for (const badCIDR of badCIDRs) { + it(`isValidCIDR returns false ${badCIDR}`, () => expect(ipaddr.isValidCIDR(badCIDR)).to.be(false)); + } + + it('isEqual', function () { + expect(ipaddr.isEqual('2001:0db8:85a3:0000:0000:8a2e:0370:7334', '2001:0db8:85a3:0000:0000:8a2e:0370:7334')).to.be(true); // same + expect(ipaddr.isEqual('2001:0db8:85a3:0000:0000:8a2e:0370:7334', '2001:0db8:85a3::0000:8a2e:0370:7334')).to.be(true); // shorthand + expect(ipaddr.isEqual('2001:db8:85A3:0000:0000:8a2e:0370:7334', '2001:0db8:85a3::0000:8a2e:370:7334')).to.be(true); // casing change and no 0 prefix + expect(ipaddr.isEqual('2001:db8:85A3:0000:0000:8a2e:0370:7334', '1.2.3.4')).to.be(false); + expect(ipaddr.isEqual('::ffff:1.2.3.4', '1.2.3.4')).to.be(true); // ipv6 mapped ipv4 + expect(ipaddr.isEqual('2002:0db8:85a3:0000:0000:8a2e:0370:7334', '2001:0db8:85a3:0000:0000:8a2e:0370:7334')).to.be(false); + }); + + it('includes', function () { + expect(ipaddr.includes('2001:0db8:85a3::/64', '2001:0db8:85a3:0000:0000:8a2e:0370:7334')).to.be(true); + expect(ipaddr.includes('2001:0db8:85a3::/64', '2002:0db8:85a3:0000:0000:8a2e:0370:7334')).to.be(false); + expect(ipaddr.includes('::ffff:0:0/96', '1.2.3.4')).to.be(true); // ipv6 mapped ipv4 + }); + }); +}); diff --git a/src/tokens.js b/src/tokens.js index 755b5f925..c99d115eb 100644 --- a/src/tokens.js +++ b/src/tokens.js @@ -34,7 +34,7 @@ const assert = require('assert'), BoxError = require('./boxerror.js'), database = require('./database.js'), hat = require('./hat.js'), - ipaddr = require('ipaddr.js'), + ipaddr = require('./ipaddr.js'), safe = require('safetydance'), uuid = require('uuid'); @@ -104,7 +104,6 @@ function parseIpRanges(ipRanges) { // each line can have comma separated list. this complexity is because we changed the UI to take a line input instead of textarea for (const entry of line.split(',')) { const rangeOrIP = entry.trim(); - // this checks for IPv4 and IPv6 if (!ipaddr.isValid(rangeOrIP) && !ipaddr.isValidCIDR(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `${rangeOrIP} is not a valid IP or range`); result.push(rangeOrIP); } @@ -209,8 +208,6 @@ function isIpAllowedSync(token, ip) { assert.strictEqual(typeof token, 'object'); assert.strictEqual(typeof ip, 'string'); - const parsedIp = ipaddr.process(ip); // will demangle IPv4-mapped IPv6 - let allowedIpRanges = gParsedRangesCache.get(token.id); // returns undefined if not found if (!allowedIpRanges) { allowedIpRanges = parseIpRanges(token.allowedIpRanges || ''); @@ -219,10 +216,9 @@ function isIpAllowedSync(token, ip) { for (const ipOrRange of allowedIpRanges) { if (!ipOrRange.includes('/')) { - if (ip === ipOrRange) return true; + if (ipaddr.isEqual(ipOrRange, ip)) return true; } else { - const parsedRange = ipaddr.parseCIDR(ipOrRange); // returns [addr, range] - if (parsedRange[0].kind() === parsedIp.kind() && parsedIp.match(parsedRange)) return true; + if (ipaddr.includes(ipOrRange, ip)) return true; } }