diff --git a/benchmark/net/net-blocklist.js b/benchmark/net/net-blocklist.js new file mode 100644 index 000000000000..9c293682ff61 --- /dev/null +++ b/benchmark/net/net-blocklist.js @@ -0,0 +1,146 @@ +'use strict'; + +const common = require('../common.js'); +const { BlockList, SocketAddress } = require('net'); + +const hasAddAddresses = typeof BlockList.prototype.addAddresses === 'function'; + +const operations = ['check', 'checkWithSocketAddress', 'addAddress']; +if (hasAddAddresses) { + operations.push('addAddresses'); +} + +const bench = common.createBenchmark(main, { + n: [1e6], + ruleCount: [10, 100, 1000, 10000], + ruleType: ['address', 'subnet', 'mixed'], + checkResult: ['hit', 'miss'], + operation: operations, +}, { + combinationFilter({ operation, ruleCount, ruleType }) { + // addAddress and addAddresses only need address rules, not subnets. + if ((operation === 'addAddress' || operation === 'addAddresses') && + ruleType !== 'address') { + return false; + } + return true; + }, +}); + +function generateIPv4(index) { + return `${(index >>> 24) & 0xff}.${(index >>> 16) & 0xff}.` + + `${(index >>> 8) & 0xff}.${index & 0xff}`; +} + +function buildBlockList(ruleCount, ruleType) { + const blockList = new BlockList(); + + if (ruleType === 'address' || ruleType === 'mixed') { + const addressCount = ruleType === 'mixed' ? + Math.floor(ruleCount / 2) : ruleCount; + const addresses = []; + for (let i = 0; i < addressCount; i++) { + // Start from 10.0.0.1 to avoid 0.0.0.0 + addresses.push(generateIPv4(0x0a000001 + i)); + } + if (hasAddAddresses) { + blockList.addAddresses(addresses); + } else { + for (const addr of addresses) { + blockList.addAddress(addr); + } + } + } + + if (ruleType === 'subnet' || ruleType === 'mixed') { + const subnetCount = ruleType === 'mixed' ? + Math.floor(ruleCount / 2) : ruleCount; + for (let i = 0; i < subnetCount; i++) { + // Use distinct /24 subnets: 172.i.j.0/24 + const second = (i >>> 8) & 0xff; + const third = i & 0xff; + blockList.addSubnet(`172.${second}.${third}.0`, 24); + } + } + + return blockList; +} + +function main({ n, ruleCount, ruleType, checkResult, operation }) { + if (operation === 'check') { + benchCheck(n, ruleCount, ruleType, checkResult); + } else if (operation === 'checkWithSocketAddress') { + benchCheckWithSocketAddress(n, ruleCount, ruleType, checkResult); + } else if (operation === 'addAddress') { + benchAddAddress(n, ruleCount); + } else if (operation === 'addAddresses') { + benchAddAddresses(n, ruleCount); + } +} + +// Benchmark check() with string addresses (the common JS API path). +function benchCheck(n, ruleCount, ruleType, checkResult) { + const blockList = buildBlockList(ruleCount, ruleType); + + // For 'hit', use an address that's in the list. + // For 'miss', use an address that's not in the list. + const address = checkResult === 'hit' ? '10.0.0.1' : '192.168.255.255'; + + bench.start(); + for (let i = 0; i < n; i++) { + blockList.check(address); + } + bench.end(n); +} + +// Benchmark check() with pre-created SocketAddress objects +// (avoids measuring SocketAddress construction overhead). +function benchCheckWithSocketAddress(n, ruleCount, ruleType, checkResult) { + const blockList = buildBlockList(ruleCount, ruleType); + + const address = checkResult === 'hit' ? '10.0.0.1' : '192.168.255.255'; + const sa = new SocketAddress({ address }); + + bench.start(); + for (let i = 0; i < n; i++) { + blockList.check(sa); + } + bench.end(n); +} + +// Benchmark single addAddress() calls (one lock acquire per call). +function benchAddAddress(n, ruleCount) { + // Scale n down for large rule counts to keep runtime reasonable. + const iterations = Math.min(n, ruleCount * 100); + + const addresses = []; + for (let i = 0; i < ruleCount; i++) { + addresses.push(generateIPv4(0x0a000001 + i)); + } + + bench.start(); + for (let i = 0; i < iterations; i++) { + const blockList = new BlockList(); + for (let j = 0; j < addresses.length; j++) { + blockList.addAddress(addresses[j]); + } + } + bench.end(iterations); +} + +// Benchmark batch addAddresses() (one lock acquire per batch). +function benchAddAddresses(n, ruleCount) { + const iterations = Math.min(n, ruleCount * 100); + + const addresses = []; + for (let i = 0; i < ruleCount; i++) { + addresses.push(generateIPv4(0x0a000001 + i)); + } + + bench.start(); + for (let i = 0; i < iterations; i++) { + const blockList = new BlockList(); + blockList.addAddresses(addresses); + } + bench.end(iterations); +} diff --git a/doc/api/net.md b/doc/api/net.md index a26abe16c5be..3b66be06cc5c 100644 --- a/doc/api/net.md +++ b/doc/api/net.md @@ -96,6 +96,47 @@ added: Adds a rule to block the given IP address. +### `blockList.addAddresses(addresses[, type])` + + + +* `addresses` {string\[]|net.SocketAddress\[]} An array of IPv4 or IPv6 + addresses. +* `type` {string} Either `'ipv4'` or `'ipv6'`. **Default:** `'ipv4'`. + +Adds multiple address rules to the block list in a single operation. +This is more efficient than calling `blockList.addAddress()` repeatedly +when adding a large number of individual addresses, as the addresses +are inserted under a single internal lock acquisition. + +### `blockList.addCIDR(cidr)` + + + +* `cidr` {string} An IPv4 or IPv6 subnet in CIDR notation (e.g. + `'10.0.0.0/8'` or `'2001:db8::/32'`). + +Adds a subnet rule using CIDR notation. The address family is automatically +detected from the address (IPv6 if the address contains `':'`, IPv4 +otherwise). This is equivalent to calling `blockList.addSubnet()` with +the parsed network address, prefix length, and family. + +### `blockList.addCIDRs(cidrs)` + + + +* `cidrs` {string\[]} An array of IPv4 or IPv6 subnets in CIDR notation. + +Adds multiple subnet rules using CIDR notation in a single call. The address +family for each entry is automatically detected. This is equivalent to +calling `blockList.addCIDR()` for each element of the array. + ### `blockList.addRange(start, end[, type])` - -* Type: {string\[]} - -The list of rules added to the blocklist. - -### `BlockList.isBlockList(value)` +### `blockList.clear()` - -* `value` {any} Any JS value -* Returns `true` if the `value` is a `net.BlockList`. +Clears all rules from the `BlockList`. ### `blockList.fromJSON(value)` @@ -205,6 +231,130 @@ blockList.fromJSON(JSON.stringify(data)); * `value` Blocklist.rules +### `BlockList.isBlockList(value)` + + + +* `value` {any} Any JS value +* Returns `true` if the `value` is a `net.BlockList`. + +### `BlockList.PRIVATE_RANGES` + + + +* Type: {string\[]} + +A frozen array of CIDR strings representing private, loopback, and link-local +IP address ranges. This can be passed to `blockList.addCIDRs()` to quickly +populate a blocklist with all non-routable address ranges. + +The included ranges are: + +* `10.0.0.0/8` — RFC 1918 private IPv4 +* `172.16.0.0/12` — RFC 1918 private IPv4 +* `192.168.0.0/16` — RFC 1918 private IPv4 +* `127.0.0.0/8` — IPv4 loopback +* `::1/128` — IPv6 loopback +* `169.254.0.0/16` — IPv4 link-local +* `fe80::/10` — IPv6 link-local +* `fc00::/7` — IPv6 unique local (ULA) + +```js +const blockList = new net.BlockList(); +blockList.addCIDRs(net.BlockList.PRIVATE_RANGES); + +console.log(blockList.check('10.0.0.1')); // Prints: true +console.log(blockList.check('127.0.0.1')); // Prints: true +console.log(blockList.check('8.8.8.8')); // Prints: false +``` + +### `blockList.removeAddress(address[, type])` + + + +* `address` {string|net.SocketAddress} An IPv4 or IPv6 address. +* `type` {string} Either `'ipv4'` or `'ipv6'`. **Default:** `'ipv4'`. + +Removes a rule that was previously added with `blockList.addAddress()`. The +address must match exactly the value used when the rule was added. If the +specified address does not exist, this is a no-op. + +### `blockList.removeCIDR(cidr)` + + + +* `cidr` {string} An IPv4 or IPv6 subnet in CIDR notation (e.g. + `'10.0.0.0/8'` or `'2001:db8::/32'`). + +Removes a subnet rule using CIDR notation. The address family is automatically +detected from the address. This is equivalent to calling +`blockList.removeSubnet()` with the parsed network address, prefix length, +and family. If the specified subnet does not exist, this is a no-op. + +### `blockList.removeRange(start, end[, type])` + + + +* `start` {string|net.SocketAddress} The starting IPv4 or IPv6 address in the + range. +* `end` {string|net.SocketAddress} The ending IPv4 or IPv6 address in the range. +* `type` {string} Either `'ipv4'` or `'ipv6'`. **Default:** `'ipv4'`. + +Removes a rule that was previously added with `blockList.addRange()`. The `start` +and `end` addresses must match exactly the values used when the rule was added. +If the specified range does not exist, this is a no-op. + +### `blockList.removeSubnet(net, prefix[, type])` + + + +* `net` {string|net.SocketAddress} The network IPv4 or IPv6 address. +* `prefix` {number} The number of CIDR prefix bits. For IPv4, this + must be a value between `0` and `32`. For IPv6, this must be between + `0` and `128`. +* `type` {string} Either `'ipv4'` or `'ipv6'`. **Default:** `'ipv4'`. + +Removes a rule that was previously added with `blockList.addSubnet()`. The +network address and prefix must match exactly the values used when the rule was +added. If the specified subnet does not exist, this is a no-op. + +### `blockList.rules` + + + +* Type: {string\[]} + +The list of rules added to the blocklist. + +### `blockList.size` + + + +* Type: {number} + +The number of rules in the blocklist. This is equivalent to +`blockList.rules.length` but does not allocate the rules array. + ### `blockList.toJSON()` > Stability: 1.2 - Release candidate diff --git a/lib/internal/blocklist.js b/lib/internal/blocklist.js index fd0e7667377b..f290c7ada405 100644 --- a/lib/internal/blocklist.js +++ b/lib/internal/blocklist.js @@ -4,13 +4,21 @@ const { ArrayIsArray, Boolean, JSONParse, + NumberIsNaN, NumberParseInt, + ObjectFreeze, ObjectSetPrototypeOf, + StringPrototypeIncludes, + StringPrototypeLastIndexOf, + StringPrototypeSlice, + StringPrototypeToLowerCase, Symbol, } = primordials; const { BlockList: BlockListHandle, + AF_INET, + AF_INET6, } = internalBinding('block_list'); const { @@ -40,6 +48,22 @@ const { const { validateInt32, validateString } = require('internal/validators'); +function parseCIDR(cidr) { + validateString(cidr, 'cidr'); + const slash = StringPrototypeLastIndexOf(cidr, '/'); + if (slash === -1) { + throw new ERR_INVALID_ARG_VALUE('cidr', cidr, 'must contain a prefix length (e.g. "10.0.0.0/8")'); + } + const address = StringPrototypeSlice(cidr, 0, slash); + const prefixStr = StringPrototypeSlice(cidr, slash + 1); + const prefix = NumberParseInt(prefixStr, 10); + if (NumberIsNaN(prefix) || `${prefix}` !== prefixStr) { + throw new ERR_INVALID_ARG_VALUE('cidr', cidr, 'prefix length must be a valid integer'); + } + const family = StringPrototypeIncludes(address, ':') ? 'ipv6' : 'ipv4'; + return { address, prefix, family }; +} + class BlockList { constructor() { markTransferMode(this, true, false); @@ -56,6 +80,21 @@ class BlockList { return value?.[kHandle] !== undefined; } + static PRIVATE_RANGES = ObjectFreeze([ + // RFC 1918 - Private IPv4 + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + // Loopback + '127.0.0.0/8', + '::1/128', + // Link-local + '169.254.0.0/16', + 'fe80::/10', + // Unique local (ULA) + 'fc00::/7', + ]); + [kInspect](depth, options) { if (depth < 0) return this; @@ -70,6 +109,10 @@ class BlockList { }, opts)}`; } + /** + * @param {string|SocketAddress} address + * @param {string} [family] + */ addAddress(address, family = 'ipv4') { if (!SocketAddress.isSocketAddress(address)) { validateString(address, 'address'); @@ -82,6 +125,33 @@ class BlockList { this[kHandle].addAddress(address[kSocketAddressHandle]); } + /** + * + * @param {(string|SocketAddress)[]} addresses + * @param {string} [family] + */ + addAddresses(addresses, family = 'ipv4') { + if (!ArrayIsArray(addresses)) { + throw new ERR_INVALID_ARG_TYPE('addresses', 'Array', addresses); + } + validateString(family, 'family'); + const handles = []; + for (let i = 0; i < addresses.length; i++) { + let address = addresses[i]; + if (!SocketAddress.isSocketAddress(address)) { + validateString(address, `addresses[${i}]`); + address = new SocketAddress({ address, family }); + } + handles.push(address[kSocketAddressHandle]); + } + this[kHandle].addAddresses(handles); + } + + /** + * @param {string|SocketAddress} start + * @param {string|SocketAddress} end + * @param {string} [family] + */ addRange(start, end, family = 'ipv4') { if (!SocketAddress.isSocketAddress(start)) { validateString(start, 'start'); @@ -106,6 +176,11 @@ class BlockList { throw new ERR_INVALID_ARG_VALUE('start', start, 'must come before end'); } + /** + * @param {string|SocketAddress} network + * @param {number} prefix + * @param {string} [family] + */ addSubnet(network, prefix, family = 'ipv4') { if (!SocketAddress.isSocketAddress(network)) { validateString(network, 'network'); @@ -128,23 +203,136 @@ class BlockList { this[kHandle].addSubnet(network[kSocketAddressHandle], prefix); } + /** + * @param {string} cidr + */ + addCIDR(cidr) { + const { address, prefix, family } = parseCIDR(cidr); + this.addSubnet(address, prefix, family); + } + + /** + * @param {string[]} cidrs + */ + addCIDRs(cidrs) { + if (!ArrayIsArray(cidrs)) { + throw new ERR_INVALID_ARG_TYPE('cidrs', 'Array', cidrs); + } + // Validate and parse all entries first so that an exception mid-array + // does not leave the blocklist half-modified. + const parsed = []; + for (let i = 0; i < cidrs.length; i++) { + validateString(cidrs[i], `cidrs[${i}]`); + parsed.push(parseCIDR(cidrs[i])); + } + for (let i = 0; i < parsed.length; i++) { + const { address, prefix, family } = parsed[i]; + this.addSubnet(address, prefix, family); + } + } + + /** + * @param {string|SocketAddress} address + * @param {string} [family] + */ + removeAddress(address, family = 'ipv4') { + if (!SocketAddress.isSocketAddress(address)) { + validateString(address, 'address'); + validateString(family, 'family'); + address = new SocketAddress({ + address, + family, + }); + } + this[kHandle].removeAddress(address[kSocketAddressHandle]); + } + + /** + * @param {string|SocketAddress} start + * @param {string|SocketAddress} end + * @param {string} [family] + */ + removeRange(start, end, family = 'ipv4') { + if (!SocketAddress.isSocketAddress(start)) { + validateString(start, 'start'); + validateString(family, 'family'); + start = new SocketAddress({ + address: start, + family, + }); + } + if (!SocketAddress.isSocketAddress(end)) { + validateString(end, 'end'); + validateString(family, 'family'); + end = new SocketAddress({ + address: end, + family, + }); + } + this[kHandle].removeRange( + start[kSocketAddressHandle], + end[kSocketAddressHandle]); + } + + /** + * @param {string|SocketAddress} network + * @param {number} prefix + * @param {string} [family] + */ + removeSubnet(network, prefix, family = 'ipv4') { + if (!SocketAddress.isSocketAddress(network)) { + validateString(network, 'network'); + validateString(family, 'family'); + network = new SocketAddress({ + address: network, + family, + }); + } + switch (network.family) { + case 'ipv4': + validateInt32(prefix, 'prefix', 0, 32); + break; + case 'ipv6': + validateInt32(prefix, 'prefix', 0, 128); + break; + } + prefix += 0; + this[kHandle].removeSubnet(network[kSocketAddressHandle], prefix); + } + + /** + * @param {string} cidr + */ + removeCIDR(cidr) { + const { address, prefix, family } = parseCIDR(cidr); + this.removeSubnet(address, prefix, family); + } + + /** + * @param {string|SocketAddress} address + * @param {string} [family] + * @returns {boolean} + */ check(address, family = 'ipv4') { if (!SocketAddress.isSocketAddress(address)) { validateString(address, 'address'); validateString(family, 'family'); - try { - address = new SocketAddress({ - address, - family, - }); - } catch { - // Ignore the error. If it's not a valid address, return false. - return false; - } + // Fast path: pass the string directly to C++ which does + // inet_pton + Apply() without allocating a JS SocketAddress wrapper. + const af = StringPrototypeToLowerCase(family) === 'ipv4' ? + AF_INET : AF_INET6; + return this[kHandle].checkString(address, af); } return Boolean(this[kHandle].check(address[kSocketAddressHandle])); } + /** + * Removes all rules from the block list. + */ + clear() { + this[kHandle].clear(); + } + /* * @param {string[]} data * @example @@ -269,6 +457,11 @@ class BlockList { get rules() { return this[kHandle].getRules(); } + + get size() { + return this[kHandle].getSize(); + } + [kClone]() { const handle = this[kHandle]; return { diff --git a/src/node_sockaddr.cc b/src/node_sockaddr.cc index 9348f0ac8e4d..08dac4bee997 100644 --- a/src/node_sockaddr.cc +++ b/src/node_sockaddr.cc @@ -3,6 +3,7 @@ #include "env-inl.h" #include "memory_tracker-inl.h" #include "nbytes.h" +#include "node_debug.h" #include "node_errors.h" #include "node_hash.h" #include "node_sockaddr-inl.h" // NOLINT(build/include_inline) @@ -15,6 +16,7 @@ namespace node { using v8::Array; +using v8::CFunction; using v8::Context; using v8::FunctionCallbackInfo; using v8::FunctionTemplate; @@ -399,106 +401,373 @@ SocketAddressBlockList::SocketAddressBlockList( std::shared_ptr parent) : parent_(parent) {} -void SocketAddressBlockList::AddSocketAddress( - const std::shared_ptr& address) { - Mutex::ScopedLock lock(mutex_); - std::unique_ptr rule = std::make_unique(address); - rules_.emplace_front(std::move(rule)); - address_rules_[*address.get()] = rules_.begin(); +// --- SubnetTrie implementation --- + +namespace { +inline int GetBit(const uint8_t* bytes, int bit_index) { + return (bytes[bit_index >> 3] >> (7 - (bit_index & 7))) & 1; +} + +inline const uint8_t* GetAddressBytes(const SocketAddress& addr, int* bits) { + if (addr.family() == AF_INET) { + const auto* in = reinterpret_cast(addr.data()); + *bits = 32; + return reinterpret_cast(&in->sin_addr); + } + const auto* in6 = reinterpret_cast(addr.data()); + *bits = 128; + return reinterpret_cast(&in6->sin6_addr); +} +} // namespace + +void SocketAddressBlockList::SubnetTrie::Insert(const uint8_t* address_bytes, + int prefix_length) { + if (root_ == nullptr) { + root_ = std::make_unique(); + } + + Node* node = root_.get(); + for (int i = 0; i < prefix_length; i++) { + if (node->terminal) { + // A broader prefix already covers this subnet. No-op. + return; + } + int bit = GetBit(address_bytes, i); + if (node->children[bit] == nullptr) { + node->children[bit] = std::make_unique(); + } + node = node->children[bit].get(); + } + + if (!node->terminal) { + node->terminal = true; + count_++; + // Prune children — this prefix subsumes all longer prefixes below it. + node->children[0].reset(); + node->children[1].reset(); + } +} + +bool SocketAddressBlockList::SubnetTrie::Lookup(const uint8_t* address_bytes, + int address_bits) const { + if (root_ == nullptr) return false; + + const Node* node = root_.get(); + // A terminal root means prefix /0 — matches everything. + if (node->terminal) return true; + + for (int i = 0; i < address_bits; i++) { + int bit = GetBit(address_bytes, i); + node = node->children[bit].get(); + if (node == nullptr) return false; + if (node->terminal) return true; + } + return false; +} + +bool SocketAddressBlockList::SubnetTrie::Remove(const uint8_t* address_bytes, + int prefix_length) { + if (root_ == nullptr) return false; + + // Walk the trie to find the node at prefix_length depth. + // Keep a stack of parent pointers so we can prune empty branches. + Node* node = root_.get(); + struct Ancestor { + Node* parent; + int bit; + }; + // Max depth is 128 bits for IPv6. + Ancestor ancestors[128]; + int depth = 0; + + for (int i = 0; i < prefix_length; i++) { + if (node->terminal) { + // A broader prefix exists — the specific prefix we're trying + // to remove is subsumed and doesn't exist as a separate entry. + return false; + } + int bit = GetBit(address_bytes, i); + if (node->children[bit] == nullptr) return false; + ancestors[depth++] = {node, bit}; + node = node->children[bit].get(); + } + + if (!node->terminal) return false; + + node->terminal = false; + count_--; + + // Prune empty leaf nodes up the tree. + for (int i = depth - 1; i >= 0; i--) { + Node* child = ancestors[i].parent->children[ancestors[i].bit].get(); + if (!child->terminal && child->children[0] == nullptr && + child->children[1] == nullptr) { + ancestors[i].parent->children[ancestors[i].bit].reset(); + } else { + break; + } + } + + // If root is now empty and non-terminal, reset it. + if (!root_->terminal && root_->children[0] == nullptr && + root_->children[1] == nullptr) { + root_.reset(); + } + + return true; +} + +void SocketAddressBlockList::SubnetTrie::Clear() { + root_.reset(); + count_ = 0; +} + +void SocketAddressBlockList::AddSocketAddressImpl( + const SocketAddress& address) { + if (address_rules_.count(address) == 0) { + address_count_++; + } + address_rules_[address] = address; + // Insert the cross-family counterpart so that both IPv4 and + // IPv4-mapped IPv6 lookups resolve in O(1). + if (address.family() == AF_INET) { + // Map 1.2.3.4 -> ::ffff:1.2.3.4 + std::string mapped = "::ffff:" + address.address(); + SocketAddress ipv6; + if (SocketAddress::New(AF_INET6, mapped.c_str(), address.port(), &ipv6)) { + address_rules_[ipv6] = address; + } + } else if (address.family() == AF_INET6) { + // Check if this is an IPv4-mapped IPv6 address (::ffff:x.x.x.x) + // and insert the IPv4 counterpart if so. + const sockaddr_in6* in6 = + reinterpret_cast(address.data()); + const uint8_t* bytes = reinterpret_cast(&in6->sin6_addr); + constexpr uint8_t ipv4_mapped_prefix[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + if (memcmp(bytes, ipv4_mapped_prefix, sizeof(ipv4_mapped_prefix)) == 0) { + sockaddr_in ipv4_addr{}; + ipv4_addr.sin_family = AF_INET; + ipv4_addr.sin_port = in6->sin6_port; + memcpy(&ipv4_addr.sin_addr, bytes + sizeof(ipv4_mapped_prefix), 4); + SocketAddress ipv4(reinterpret_cast(&ipv4_addr)); + address_rules_[ipv4] = address; + } + } +} + +void SocketAddressBlockList::AddSocketAddress(const SocketAddress& address) { + RwLock::ScopedLock lock(mutex_); + AddSocketAddressImpl(address); } -void SocketAddressBlockList::RemoveSocketAddress( - const std::shared_ptr& address) { - Mutex::ScopedLock lock(mutex_); - auto it = address_rules_.find(*address.get()); - if (it != std::end(address_rules_)) { - rules_.erase(it->second); - address_rules_.erase(it); +void SocketAddressBlockList::AddSocketAddresses(const SocketAddress* addresses, + size_t count) { + RwLock::ScopedLock lock(mutex_); + for (size_t i = 0; i < count; i++) { + AddSocketAddressImpl(addresses[i]); } } -void SocketAddressBlockList::AddSocketAddressRange( - const std::shared_ptr& start, - const std::shared_ptr& end) { - Mutex::ScopedLock lock(mutex_); +void SocketAddressBlockList::RemoveSocketAddress(const SocketAddress& address) { + RwLock::ScopedLock lock(mutex_); + if (address_rules_.erase(address)) { + address_count_--; + } + // Also remove the cross-family counterpart. + if (address.family() == AF_INET) { + std::string mapped = "::ffff:" + address.address(); + SocketAddress ipv6; + if (SocketAddress::New(AF_INET6, mapped.c_str(), address.port(), &ipv6)) { + address_rules_.erase(ipv6); + } + } else if (address.family() == AF_INET6) { + const sockaddr_in6* in6 = + reinterpret_cast(address.data()); + const uint8_t* bytes = reinterpret_cast(&in6->sin6_addr); + constexpr uint8_t ipv4_mapped_prefix[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + if (memcmp(bytes, ipv4_mapped_prefix, sizeof(ipv4_mapped_prefix)) == 0) { + sockaddr_in ipv4_addr{}; + ipv4_addr.sin_family = AF_INET; + ipv4_addr.sin_port = in6->sin6_port; + memcpy(&ipv4_addr.sin_addr, bytes + sizeof(ipv4_mapped_prefix), 4); + SocketAddress ipv4(reinterpret_cast(&ipv4_addr)); + address_rules_.erase(ipv4); + } + } +} + +void SocketAddressBlockList::AddSocketAddressRange(const SocketAddress& start, + const SocketAddress& end) { + DCHECK(!(start > end)); + RwLock::ScopedLock lock(mutex_); std::unique_ptr rule = std::make_unique(start, end); rules_.emplace_front(std::move(rule)); } -void SocketAddressBlockList::AddSocketAddressMask( - const std::shared_ptr& network, int prefix) { - Mutex::ScopedLock lock(mutex_); - std::unique_ptr rule = - std::make_unique(network, prefix); - rules_.emplace_front(std::move(rule)); +void SocketAddressBlockList::AddSocketAddressMask(const SocketAddress& network, + int prefix) { + RwLock::ScopedLock lock(mutex_); + int bits; + const uint8_t* bytes = GetAddressBytes(network, &bits); + + if (network.family() == AF_INET) { + ipv4_subnets_.Insert(bytes, prefix); + // Also insert into IPv6 trie as ::ffff:x.x.x.x with prefix+96. + uint8_t mapped[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + memcpy(mapped + 12, bytes, 4); + ipv6_subnets_.Insert(mapped, prefix + 96); + } else { + ipv6_subnets_.Insert(bytes, prefix); + // Check if this is a ::ffff:x.x.x.x/N subnet — if so, also insert + // the IPv4 portion into the IPv4 trie. + constexpr uint8_t v4mapped[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + if (prefix >= 96 && memcmp(bytes, v4mapped, 12) == 0) { + ipv4_subnets_.Insert(bytes + 12, prefix - 96); + } + } + + // Keep metadata for ListRules serialization. + subnet_rules_.emplace_front( + std::make_unique(network, prefix)); +} + +void SocketAddressBlockList::RemoveSocketAddressRange( + const SocketAddress& start, const SocketAddress& end) { + RwLock::ScopedLock lock(mutex_); + // rules_ contains only SocketAddressRangeRule instances (subnet rules + // are stored separately in subnet_rules_). + for (auto it = rules_.begin(); it != rules_.end(); ++it) { + auto* range = static_cast(it->get()); + if (range->start == start && range->end == end) { + rules_.erase(it); + return; + } + } +} + +void SocketAddressBlockList::RemoveSocketAddressMask( + const SocketAddress& network, int prefix) { + RwLock::ScopedLock lock(mutex_); + + // Remove from subnet_rules_ metadata list. + bool found = false; + for (auto it = subnet_rules_.begin(); it != subnet_rules_.end(); ++it) { + if ((*it)->network == network && (*it)->prefix == prefix) { + subnet_rules_.erase(it); + found = true; + break; + } + } + if (!found) return; + + // Rebuild both tries from the remaining subnet_rules_. This handles the + // case where a broader prefix had subsumed narrower ones in the trie -- + // simply removing the broader prefix from the trie would not restore the + // narrower entries that were pruned on insert. Rebuilding is O(n) in the + // number of subnet rules but removal is not a hot path. + ipv4_subnets_.Clear(); + ipv6_subnets_.Clear(); + for (const auto& rule : subnet_rules_) { + int bits; + const uint8_t* b = GetAddressBytes(rule->network, &bits); + if (rule->network.family() == AF_INET) { + ipv4_subnets_.Insert(b, rule->prefix); + uint8_t mapped[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + memcpy(mapped + 12, b, 4); + ipv6_subnets_.Insert(mapped, rule->prefix + 96); + } else { + ipv6_subnets_.Insert(b, rule->prefix); + constexpr uint8_t v4mapped[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + if (rule->prefix >= 96 && memcmp(b, v4mapped, 12) == 0) { + ipv4_subnets_.Insert(b + 12, rule->prefix - 96); + } + } + } } bool SocketAddressBlockList::Apply(const SocketAddress& address) { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); + // O(1) lookup for exact address matches. The address_rules_ map + // uses IpHash/IpEqual (port-insensitive, family-sensitive). + if (address_rules_.count(address)) return true; + + // O(prefix_length) lookup for subnet/mask rules via radix trie. + int bits; + const uint8_t* bytes = GetAddressBytes(address, &bits); + if (address.family() == AF_INET) { + if (ipv4_subnets_.Lookup(bytes, bits)) return true; + // Also check IPv6 trie for ::ffff:x.x.x.x subnets. + uint8_t mapped[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + memcpy(mapped + 12, bytes, 4); + if (ipv6_subnets_.Lookup(mapped, 128)) return true; + } else { + if (ipv6_subnets_.Lookup(bytes, bits)) return true; + // Check if this is ::ffff:x.x.x.x — also check IPv4 trie. + constexpr uint8_t v4mapped[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + if (memcmp(bytes, v4mapped, 12) == 0) { + if (ipv4_subnets_.Lookup(bytes + 12, 32)) return true; + } + } + + // Linear scan for range rules only. Subnet rules are in the trie. for (const auto& rule : rules_) { if (rule->Apply(address)) return true; } return parent_ ? parent_->Apply(address) : false; } -SocketAddressBlockList::SocketAddressRule::SocketAddressRule( - const std::shared_ptr& address_) - : address(address_) {} +void SocketAddressBlockList::Clear() { + RwLock::ScopedLock lock(mutex_); + rules_.clear(); + address_rules_.clear(); + address_count_ = 0; + ipv4_subnets_.Clear(); + ipv6_subnets_.Clear(); + subnet_rules_.clear(); +} SocketAddressBlockList::SocketAddressRangeRule::SocketAddressRangeRule( - const std::shared_ptr& start_, - const std::shared_ptr& end_) + const SocketAddress& start_, const SocketAddress& end_) : start(start_), end(end_) {} SocketAddressBlockList::SocketAddressMaskRule::SocketAddressMaskRule( - const std::shared_ptr& network_, int prefix_) + const SocketAddress& network_, int prefix_) : network(network_), prefix(prefix_) {} -bool SocketAddressBlockList::SocketAddressRule::Apply( - const SocketAddress& address) { - return this->address->is_match(address); -} - -std::string SocketAddressBlockList::SocketAddressRule::ToString() { - std::string ret = "Address: "; - ret += address->family() == AF_INET ? "IPv4" : "IPv6"; - ret += " "; - ret += address->address(); - return ret; -} - bool SocketAddressBlockList::SocketAddressRangeRule::Apply( const SocketAddress& address) { - return address >= *start.get() && address <= *end.get(); + return address >= start && address <= end; } std::string SocketAddressBlockList::SocketAddressRangeRule::ToString() { std::string ret = "Range: "; - ret += start->family() == AF_INET ? "IPv4" : "IPv6"; + ret += start.family() == AF_INET ? "IPv4" : "IPv6"; ret += " "; - ret += start->address(); + ret += start.address(); ret += "-"; - ret += end->address(); + ret += end.address(); return ret; } bool SocketAddressBlockList::SocketAddressMaskRule::Apply( const SocketAddress& address) { - return address.is_in_network(*network.get(), prefix); + return address.is_in_network(network, prefix); } std::string SocketAddressBlockList::SocketAddressMaskRule::ToString() { std::string ret = "Subnet: "; - ret += network->family() == AF_INET ? "IPv4" : "IPv6"; + ret += network.family() == AF_INET ? "IPv4" : "IPv6"; ret += " "; - ret += network->address(); + ret += network.address(); ret += "/" + std::to_string(prefix); return ret; } MaybeLocal SocketAddressBlockList::ListRules(Environment* env) { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); LocalVector rules(env->isolate()); if (!ListRules(env, &rules)) return MaybeLocal(); return Array::New(env->isolate(), rules.data(), rules.size()); @@ -506,22 +775,42 @@ MaybeLocal SocketAddressBlockList::ListRules(Environment* env) { bool SocketAddressBlockList::ListRules(Environment* env, LocalVector* rules) { - if (parent_ && !parent_->ListRules(env, rules)) return false; + // List local rules first, then parent rules, matching the + // evaluation order in Apply(). + // + // address_rules_ may contain cross-family duplicates (e.g. both + // 1.1.1.1 and ::ffff:1.1.1.1 map to the same original address). + // Track which originals have been listed to avoid duplicates. + SocketAddress::Map seen; + for (const auto& [_, address] : address_rules_) { + if (seen.count(address)) continue; + seen[address] = true; + std::string str = "Address: "; + str += address.family() == AF_INET ? "IPv4" : "IPv6"; + str += " "; + str += address.address(); + Local v; + if (!ToV8Value(env->context(), str).ToLocal(&v)) return false; + rules->push_back(v); + } + for (const auto& rule : subnet_rules_) { + Local str; + if (!rule->ToV8String(env).ToLocal(&str)) return false; + rules->push_back(str); + } for (const auto& rule : rules_) { Local str; if (!rule->ToV8String(env).ToLocal(&str)) return false; rules->push_back(str); } - return true; + return !parent_ || parent_->ListRules(env, rules); } void SocketAddressBlockList::MemoryInfo(node::MemoryTracker* tracker) const { tracker->TrackField("rules", rules_); -} - -void SocketAddressBlockList::SocketAddressRule::MemoryInfo( - node::MemoryTracker* tracker) const { - tracker->TrackField("address", address); + tracker->TrackFieldWithSize("address_rules", + address_rules_.size() * sizeof(SocketAddress)); + tracker->TrackField("subnet_rules", subnet_rules_); } void SocketAddressBlockList::SocketAddressRangeRule::MemoryInfo( @@ -590,8 +879,34 @@ void SocketAddressBlockListWrap::AddAddress( SocketAddressBase* addr; ASSIGN_OR_RETURN_UNWRAP(&addr, args[0]); - wrap->blocklist_->AddSocketAddress(addr->address()); + wrap->blocklist_->AddSocketAddress(*addr->address()); + + args.GetReturnValue().Set(true); +} + +void SocketAddressBlockListWrap::AddAddresses( + const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + + CHECK(args[0]->IsArray()); + Local arr = args[0].As(); + uint32_t len = arr->Length(); + + std::vector addresses; + addresses.reserve(len); + + for (uint32_t i = 0; i < len; i++) { + Local item; + if (!arr->Get(env->context(), i).ToLocal(&item)) return; + CHECK(SocketAddressBase::HasInstance(env, item)); + SocketAddressBase* addr; + ASSIGN_OR_RETURN_UNWRAP(&addr, item.As()); + addresses.push_back(*addr->address()); + } + wrap->blocklist_->AddSocketAddresses(addresses.data(), addresses.size()); args.GetReturnValue().Set(true); } @@ -610,11 +925,11 @@ void SocketAddressBlockListWrap::AddRange( ASSIGN_OR_RETURN_UNWRAP(&end_addr, args[1]); // Starting address must come before the end address - if (*start_addr->address().get() > *end_addr->address().get()) + if (*start_addr->address() > *end_addr->address()) return args.GetReturnValue().Set(false); - wrap->blocklist_->AddSocketAddressRange(start_addr->address(), - end_addr->address()); + wrap->blocklist_->AddSocketAddressRange(*start_addr->address(), + *end_addr->address()); args.GetReturnValue().Set(true); } @@ -640,11 +955,62 @@ void SocketAddressBlockListWrap::AddSubnet( CHECK_IMPLIES(addr->address()->family() == AF_INET6, prefix <= 128); CHECK_GE(prefix, 0); - wrap->blocklist_->AddSocketAddressMask(addr->address(), prefix); + wrap->blocklist_->AddSocketAddressMask(*addr->address(), prefix); args.GetReturnValue().Set(true); } +void SocketAddressBlockListWrap::RemoveAddress( + const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + + CHECK(SocketAddressBase::HasInstance(env, args[0])); + SocketAddressBase* addr; + ASSIGN_OR_RETURN_UNWRAP(&addr, args[0]); + + wrap->blocklist_->RemoveSocketAddress(*addr->address()); +} + +void SocketAddressBlockListWrap::RemoveRange( + const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + + CHECK(SocketAddressBase::HasInstance(env, args[0])); + CHECK(SocketAddressBase::HasInstance(env, args[1])); + + SocketAddressBase* start_addr; + SocketAddressBase* end_addr; + ASSIGN_OR_RETURN_UNWRAP(&start_addr, args[0]); + ASSIGN_OR_RETURN_UNWRAP(&end_addr, args[1]); + + wrap->blocklist_->RemoveSocketAddressRange(*start_addr->address(), + *end_addr->address()); +} + +void SocketAddressBlockListWrap::RemoveSubnet( + const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + + CHECK(SocketAddressBase::HasInstance(env, args[0])); + CHECK(args[1]->IsInt32()); + + SocketAddressBase* addr; + ASSIGN_OR_RETURN_UNWRAP(&addr, args[0]); + + int32_t prefix; + if (!args[1]->Int32Value(env->context()).To(&prefix)) { + return; + } + + wrap->blocklist_->RemoveSocketAddressMask(*addr->address(), prefix); +} + void SocketAddressBlockListWrap::Check( const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); @@ -658,6 +1024,39 @@ void SocketAddressBlockListWrap::Check( args.GetReturnValue().Set(wrap->blocklist_->Apply(*addr->address())); } +bool SocketAddressBlockListWrap::FastCheck(Local receiver, + Local addr_obj) { + TRACK_V8_FAST_API_CALL("blocklist.check"); + SocketAddressBlockListWrap* wrap = + FromJSObject(receiver); + SocketAddressBase* addr = FromJSObject(addr_obj); + return wrap->blocklist_->Apply(*addr->address()); +} + +CFunction SocketAddressBlockListWrap::fast_check_( + CFunction::Make(&SocketAddressBlockListWrap::FastCheck)); + +void SocketAddressBlockListWrap::CheckString( + const FunctionCallbackInfo& args) { + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + + CHECK(args[0]->IsString()); + CHECK(args[1]->IsInt32()); + + Utf8Value address(args.GetIsolate(), args[0]); + int32_t family = args[1].As()->Value(); + + SocketAddress addr; + if (!SocketAddress::New(family, *address, 0, &addr)) { + // Invalid address string — return false (not blocked). + args.GetReturnValue().Set(false); + return; + } + + args.GetReturnValue().Set(wrap->blocklist_->Apply(addr)); +} + void SocketAddressBlockListWrap::GetRules( const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); @@ -668,6 +1067,20 @@ void SocketAddressBlockListWrap::GetRules( args.GetReturnValue().Set(rules); } +void SocketAddressBlockListWrap::GetSize( + const FunctionCallbackInfo& args) { + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + args.GetReturnValue().Set(static_cast(wrap->blocklist_->size())); +} + +void SocketAddressBlockListWrap::Clear( + const FunctionCallbackInfo& args) { + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + wrap->blocklist_->Clear(); +} + void SocketAddressBlockListWrap::MemoryInfo(MemoryTracker* tracker) const { blocklist_->MemoryInfo(tracker); } @@ -691,10 +1104,18 @@ Local SocketAddressBlockListWrap::GetConstructorTemplate( tmpl->SetClassName(FIXED_ONE_BYTE_STRING(env->isolate(), "BlockList")); tmpl->InstanceTemplate()->SetInternalFieldCount(kInternalFieldCount); SetProtoMethod(isolate, tmpl, "addAddress", AddAddress); + SetProtoMethod(isolate, tmpl, "addAddresses", AddAddresses); SetProtoMethod(isolate, tmpl, "addRange", AddRange); SetProtoMethod(isolate, tmpl, "addSubnet", AddSubnet); - SetProtoMethod(isolate, tmpl, "check", Check); + SetProtoMethod(isolate, tmpl, "removeAddress", RemoveAddress); + SetProtoMethod(isolate, tmpl, "removeRange", RemoveRange); + SetProtoMethod(isolate, tmpl, "removeSubnet", RemoveSubnet); + SetFastMethod( + isolate, tmpl->PrototypeTemplate(), "check", Check, &fast_check_); + SetProtoMethod(isolate, tmpl, "checkString", CheckString); SetProtoMethod(isolate, tmpl, "getRules", GetRules); + SetProtoMethodNoSideEffect(isolate, tmpl, "getSize", GetSize); + SetProtoMethod(isolate, tmpl, "clear", Clear); env->set_blocklist_constructor_template(tmpl); } return tmpl; diff --git a/src/node_sockaddr.h b/src/node_sockaddr.h index 05bb127b012f..0c7bdfd15b5f 100644 --- a/src/node_sockaddr.h +++ b/src/node_sockaddr.h @@ -248,19 +248,29 @@ class SocketAddressBlockList : public MemoryRetainer { std::shared_ptr parent = {}); ~SocketAddressBlockList() = default; - void AddSocketAddress(const std::shared_ptr& address); + void AddSocketAddress(const SocketAddress& address); - void RemoveSocketAddress(const std::shared_ptr& address); + void AddSocketAddresses(const SocketAddress* addresses, size_t count); - void AddSocketAddressRange(const std::shared_ptr& start, - const std::shared_ptr& end); + void RemoveSocketAddress(const SocketAddress& address); - void AddSocketAddressMask(const std::shared_ptr& address, - int prefix); + void AddSocketAddressRange(const SocketAddress& start, + const SocketAddress& end); + + void RemoveSocketAddressRange(const SocketAddress& start, + const SocketAddress& end); + + void AddSocketAddressMask(const SocketAddress& address, int prefix); + + void RemoveSocketAddressMask(const SocketAddress& address, int prefix); bool Apply(const SocketAddress& address); - size_t size() const { return rules_.size(); } + void Clear(); + + size_t size() const { + return address_count_ + rules_.size() + subnet_rules_.size(); + } v8::MaybeLocal ListRules(Environment* env); @@ -270,25 +280,12 @@ class SocketAddressBlockList : public MemoryRetainer { virtual std::string ToString() = 0; }; - struct SocketAddressRule final : Rule { - std::shared_ptr address; - - explicit SocketAddressRule(const std::shared_ptr& address); - - bool Apply(const SocketAddress& address) override; - std::string ToString() override; - - void MemoryInfo(node::MemoryTracker* tracker) const override; - SET_MEMORY_INFO_NAME(SocketAddressRule) - SET_SELF_SIZE(SocketAddressRule) - }; - struct SocketAddressRangeRule final : Rule { - std::shared_ptr start; - std::shared_ptr end; + SocketAddress start; + SocketAddress end; - SocketAddressRangeRule(const std::shared_ptr& start, - const std::shared_ptr& end); + SocketAddressRangeRule(const SocketAddress& start, + const SocketAddress& end); bool Apply(const SocketAddress& address) override; std::string ToString() override; @@ -299,11 +296,10 @@ class SocketAddressBlockList : public MemoryRetainer { }; struct SocketAddressMaskRule final : Rule { - std::shared_ptr network; + SocketAddress network; int prefix; - SocketAddressMaskRule(const std::shared_ptr& address, - int prefix); + SocketAddressMaskRule(const SocketAddress& address, int prefix); bool Apply(const SocketAddress& address) override; std::string ToString() override; @@ -317,14 +313,72 @@ class SocketAddressBlockList : public MemoryRetainer { SET_MEMORY_INFO_NAME(SocketAddressBlockList) SET_SELF_SIZE(SocketAddressBlockList) + // A compressed radix trie for O(prefix_length) subnet lookups. + // Each node has two children (bit 0, bit 1). A node marked + // terminal means all addresses matching the prefix up to that + // depth are blocked. On insert, if a new prefix is shorter than + // or equal to an existing one, the subtree is pruned (the shorter + // prefix subsumes all longer ones). On lookup, we walk the bits + // of the address and return true as soon as we hit a terminal node. + class SubnetTrie { + public: + SubnetTrie() = default; + ~SubnetTrie() = default; + + // Insert a subnet (network address bytes, prefix length in bits). + // If a broader prefix already exists, the insert is a no-op. + // If this prefix is broader than existing children, they are pruned. + void Insert(const uint8_t* address_bytes, int prefix_length); + + // Returns true if the given address falls within any inserted subnet. + bool Lookup(const uint8_t* address_bytes, int address_bits) const; + + // Remove a previously inserted subnet. Returns true if it was found + // and removed. + bool Remove(const uint8_t* address_bytes, int prefix_length); + + // Remove all entries. + void Clear(); + + bool empty() const { return root_ == nullptr; } + + size_t size() const { return count_; } + + private: + struct Node { + std::unique_ptr children[2]; + bool terminal = false; + }; + + std::unique_ptr root_; + size_t count_ = 0; + }; + private: + // Lock-free implementation used by both AddSocketAddress and + // AddSocketAddresses. Caller must hold the write lock. + void AddSocketAddressImpl(const SocketAddress& address); bool ListRules(Environment* env, v8::LocalVector* vec); std::shared_ptr parent_; + // Range rules only. Scanned linearly by Apply(). std::list> rules_; - SocketAddress::Map>::iterator> address_rules_; - - Mutex mutex_; + // Exact address rules. Keyed by IP only (port-insensitive) so that + // Apply() can perform O(1) lookups regardless of the port on the + // checked address. Not included in rules_ to avoid redundant scanning. + SocketAddress::IpMap address_rules_; + // User-visible address count (not inflated by cross-family dual-insert). + size_t address_count_ = 0; + // Subnet/mask rules stored in radix tries for O(prefix_length) lookup. + // Separate tries for IPv4 (max 32-bit depth) and IPv6 (max 128-bit). + SubnetTrie ipv4_subnets_; + SubnetTrie ipv6_subnets_; + // Subnet metadata kept for ListRules serialization only. + std::list> subnet_rules_; + + // RwLock allows concurrent Apply() calls (shared/read lock) while + // mutations (Add*/Remove*/Clear) take an exclusive/write lock. + mutable RwLock mutex_; }; class SocketAddressBlockListWrap : public BaseObject { @@ -343,10 +397,19 @@ class SocketAddressBlockListWrap : public BaseObject { static void New(const v8::FunctionCallbackInfo& args); static void AddAddress(const v8::FunctionCallbackInfo& args); + static void AddAddresses(const v8::FunctionCallbackInfo& args); static void AddRange(const v8::FunctionCallbackInfo& args); static void AddSubnet(const v8::FunctionCallbackInfo& args); + static void RemoveAddress(const v8::FunctionCallbackInfo& args); + static void RemoveRange(const v8::FunctionCallbackInfo& args); + static void RemoveSubnet(const v8::FunctionCallbackInfo& args); static void Check(const v8::FunctionCallbackInfo& args); + static bool FastCheck(v8::Local receiver, + v8::Local addr_obj); + static void CheckString(const v8::FunctionCallbackInfo& args); static void GetRules(const v8::FunctionCallbackInfo& args); + static void GetSize(const v8::FunctionCallbackInfo& args); + static void Clear(const v8::FunctionCallbackInfo& args); SocketAddressBlockListWrap(Environment* env, v8::Local wrap, @@ -390,6 +453,7 @@ class SocketAddressBlockListWrap : public BaseObject { private: std::shared_ptr blocklist_; + static v8::CFunction fast_check_; }; } // namespace node diff --git a/test/cctest/test_sockaddr.cc b/test/cctest/test_sockaddr.cc index a4feefd6f4b3..795511666f3a 100644 --- a/test/cctest/test_sockaddr.cc +++ b/test/cctest/test_sockaddr.cc @@ -272,6 +272,127 @@ TEST(SocketAddress, Comparison) { CHECK(addr2 >= addr5); } +TEST(SocketAddress, NewAutoFamily) { + // SocketAddress::New(host, port) without explicit family. + // Tries AF_INET first, then AF_INET6. + SocketAddress addr; + + // IPv4 address should succeed. + CHECK(SocketAddress::New("192.168.1.1", 8080, &addr)); + CHECK_EQ(addr.family(), AF_INET); + CHECK_EQ(addr.address(), "192.168.1.1"); + CHECK_EQ(addr.port(), 8080); + + // IPv6 address should succeed (fails AF_INET, falls through to AF_INET6). + CHECK(SocketAddress::New("::1", 443, &addr)); + CHECK_EQ(addr.family(), AF_INET6); + CHECK_EQ(addr.address(), "::1"); + CHECK_EQ(addr.port(), 443); + + // Invalid address should fail. + CHECK(!SocketAddress::New("not_an_address", 0, &addr)); +} + +TEST(SocketAddress, HashIPv6) { + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET6, "::1", 443, &s1); + SocketAddress::ToSockAddr(AF_INET6, "::1", 443, &s2); + SocketAddress::ToSockAddr(AF_INET6, "::2", 443, &s3); + + SocketAddress a1(reinterpret_cast(&s1)); + SocketAddress a2(reinterpret_cast(&s2)); + SocketAddress a3(reinterpret_cast(&s3)); + + // Same address and port: hash must be equal. + CHECK_EQ(SocketAddress::Hash()(a1), SocketAddress::Hash()(a2)); + + // Different address: hash should (very likely) differ. + CHECK_NE(SocketAddress::Hash()(a1), SocketAddress::Hash()(a3)); +} + +TEST(SocketAddress, IsMatchCrossFamily) { + sockaddr_storage s1, s2, s3, s4; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET6, "::ffff:10.0.0.1", 0, &s2); + SocketAddress::ToSockAddr(AF_INET6, "::1", 0, &s3); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.2", 0, &s4); + + SocketAddress ipv4(reinterpret_cast(&s1)); + SocketAddress mapped(reinterpret_cast(&s2)); + SocketAddress ipv6(reinterpret_cast(&s3)); + SocketAddress other(reinterpret_cast(&s4)); + + // IPv4 matches its IPv4-mapped IPv6 counterpart. + CHECK(ipv4.is_match(mapped)); + CHECK(mapped.is_match(ipv4)); + + // IPv4 does not match a non-mapped IPv6 address. + CHECK(!ipv4.is_match(ipv6)); + CHECK(!ipv6.is_match(ipv4)); + + // Same family, different address. + CHECK(!ipv4.is_match(other)); + + // Self-match. + CHECK(ipv4.is_match(ipv4)); + CHECK(ipv6.is_match(ipv6)); +} + +TEST(SocketAddress, InNetworkIPv4) { + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "192.168.1.100", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "192.168.0.0", 0, &s2); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 0, &s3); + + SocketAddress addr(reinterpret_cast(&s1)); + SocketAddress net(reinterpret_cast(&s2)); + SocketAddress outside(reinterpret_cast(&s3)); + + CHECK(addr.is_in_network(net, 16)); + CHECK(!outside.is_in_network(net, 16)); + CHECK(!addr.is_in_network(net, 24)); // 192.168.1.x != 192.168.0.x +} + +TEST(SocketAddress, InNetworkIPv6) { + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET6, "2001:db8::1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET6, "2001:db8::", 0, &s2); + SocketAddress::ToSockAddr(AF_INET6, "2001:db9::1", 0, &s3); + + SocketAddress addr(reinterpret_cast(&s1)); + SocketAddress net(reinterpret_cast(&s2)); + SocketAddress outside(reinterpret_cast(&s3)); + + CHECK(addr.is_in_network(net, 32)); + CHECK(!outside.is_in_network(net, 32)); + + // /128 prefix == exact match. + CHECK(addr.is_in_network(addr, 128)); + CHECK(!outside.is_in_network(addr, 128)); +} + +TEST(SocketAddress, InNetworkCrossFamily) { + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET6, "::ffff:10.0.0.0", 0, &s2); + SocketAddress::ToSockAddr(AF_INET6, "::ffff:10.0.0.1", 0, &s3); + + SocketAddress ipv4(reinterpret_cast(&s1)); + SocketAddress net6(reinterpret_cast(&s2)); + SocketAddress mapped(reinterpret_cast(&s3)); + + // IPv4 address in an IPv4-mapped IPv6 subnet. + CHECK(ipv4.is_in_network(net6, 120)); // prefix 120 = /24 on the IPv4 part + CHECK(mapped.is_in_network(net6, 120)); + + // IPv6 address in IPv4 network. + sockaddr_storage s4; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.0", 0, &s4); + SocketAddress net4(reinterpret_cast(&s4)); + + CHECK(mapped.is_in_network(net4, 24)); +} + TEST(SocketAddressBlockList, Simple) { SocketAddressBlockList bl; @@ -283,14 +404,265 @@ TEST(SocketAddressBlockList, Simple) { std::shared_ptr addr2 = std::make_shared( reinterpret_cast(&storage[1])); - bl.AddSocketAddress(addr1); - bl.AddSocketAddress(addr2); + bl.AddSocketAddress(*addr1); + bl.AddSocketAddress(*addr2); CHECK(bl.Apply(*addr1)); CHECK(bl.Apply(*addr2)); - bl.RemoveSocketAddress(addr1); + bl.RemoveSocketAddress(*addr1); CHECK(!bl.Apply(*addr1)); CHECK(bl.Apply(*addr2)); } + +TEST(SocketAddressBlockList, CrossFamilyAddress) { + SocketAddressBlockList bl; + + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET6, "::ffff:10.0.0.1", 0, &s2); + SocketAddress::ToSockAddr(AF_INET6, "::1", 0, &s3); + + SocketAddress ipv4(reinterpret_cast(&s1)); + SocketAddress mapped(reinterpret_cast(&s2)); + SocketAddress other(reinterpret_cast(&s3)); + + // Adding IPv4 should also match the IPv4-mapped IPv6 form. + bl.AddSocketAddress(ipv4); + CHECK(bl.Apply(ipv4)); + CHECK(bl.Apply(mapped)); + CHECK(!bl.Apply(other)); + + // Remove should clean up cross-family counterpart. + bl.RemoveSocketAddress(ipv4); + CHECK(!bl.Apply(ipv4)); + CHECK(!bl.Apply(mapped)); +} + +TEST(SocketAddressBlockList, CrossFamilyAddressIPv6) { + SocketAddressBlockList bl; + + sockaddr_storage s1, s2; + SocketAddress::ToSockAddr(AF_INET6, "::ffff:192.168.1.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "192.168.1.1", 0, &s2); + + SocketAddress mapped(reinterpret_cast(&s1)); + SocketAddress ipv4(reinterpret_cast(&s2)); + + // Adding an IPv4-mapped IPv6 address should also match the IPv4 form. + bl.AddSocketAddress(mapped); + CHECK(bl.Apply(mapped)); + CHECK(bl.Apply(ipv4)); + + // Remove the IPv6 form should clean up the IPv4 counterpart. + bl.RemoveSocketAddress(mapped); + CHECK(!bl.Apply(mapped)); + CHECK(!bl.Apply(ipv4)); +} + +TEST(SocketAddressBlockList, BatchAddresses) { + SocketAddressBlockList bl; + + sockaddr_storage storage[3]; + SocketAddress::ToSockAddr(AF_INET, "1.1.1.1", 0, &storage[0]); + SocketAddress::ToSockAddr(AF_INET, "2.2.2.2", 0, &storage[1]); + SocketAddress::ToSockAddr(AF_INET, "3.3.3.3", 0, &storage[2]); + + SocketAddress addrs[3] = { + SocketAddress(reinterpret_cast(&storage[0])), + SocketAddress(reinterpret_cast(&storage[1])), + SocketAddress(reinterpret_cast(&storage[2])), + }; + + bl.AddSocketAddresses(addrs, 3); + + CHECK(bl.Apply(addrs[0])); + CHECK(bl.Apply(addrs[1])); + CHECK(bl.Apply(addrs[2])); + + sockaddr_storage s4; + SocketAddress::ToSockAddr(AF_INET, "4.4.4.4", 0, &s4); + SocketAddress addr4(reinterpret_cast(&s4)); + CHECK(!bl.Apply(addr4)); +} + +TEST(SocketAddressBlockList, Range) { + SocketAddressBlockList bl; + + sockaddr_storage s1, s2, s3, s4, s5; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.10", 0, &s2); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.5", 0, &s3); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.11", 0, &s4); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.0", 0, &s5); + + SocketAddress start(reinterpret_cast(&s1)); + SocketAddress end(reinterpret_cast(&s2)); + SocketAddress mid(reinterpret_cast(&s3)); + SocketAddress above(reinterpret_cast(&s4)); + SocketAddress below(reinterpret_cast(&s5)); + + bl.AddSocketAddressRange(start, end); + + CHECK(bl.Apply(start)); + CHECK(bl.Apply(end)); + CHECK(bl.Apply(mid)); + CHECK(!bl.Apply(above)); + CHECK(!bl.Apply(below)); + + // Remove range. + bl.RemoveSocketAddressRange(start, end); + CHECK(!bl.Apply(mid)); +} + +TEST(SocketAddressBlockList, Subnet) { + SocketAddressBlockList bl; + + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "192.168.1.0", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "192.168.1.100", 0, &s2); + SocketAddress::ToSockAddr(AF_INET, "192.168.2.1", 0, &s3); + + SocketAddress net(reinterpret_cast(&s1)); + SocketAddress inside(reinterpret_cast(&s2)); + SocketAddress outside(reinterpret_cast(&s3)); + + bl.AddSocketAddressMask(net, 24); + + CHECK(bl.Apply(inside)); + CHECK(!bl.Apply(outside)); + + // Remove subnet. + bl.RemoveSocketAddressMask(net, 24); + CHECK(!bl.Apply(inside)); +} + +TEST(SocketAddressBlockList, SubnetIPv6) { + SocketAddressBlockList bl; + + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET6, "2001:db8::", 0, &s1); + SocketAddress::ToSockAddr(AF_INET6, "2001:db8::1", 0, &s2); + SocketAddress::ToSockAddr(AF_INET6, "2001:db9::1", 0, &s3); + + SocketAddress net(reinterpret_cast(&s1)); + SocketAddress inside(reinterpret_cast(&s2)); + SocketAddress outside(reinterpret_cast(&s3)); + + bl.AddSocketAddressMask(net, 32); + + CHECK(bl.Apply(inside)); + CHECK(!bl.Apply(outside)); + + bl.RemoveSocketAddressMask(net, 32); + CHECK(!bl.Apply(inside)); +} + +TEST(SocketAddressBlockList, SubnetCrossFamily) { + SocketAddressBlockList bl; + + // Adding an IPv4 subnet should also match IPv4-mapped IPv6 addresses. + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.0", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.5", 0, &s2); + SocketAddress::ToSockAddr(AF_INET6, "::ffff:10.0.0.5", 0, &s3); + + SocketAddress net(reinterpret_cast(&s1)); + SocketAddress ipv4(reinterpret_cast(&s2)); + SocketAddress mapped(reinterpret_cast(&s3)); + + bl.AddSocketAddressMask(net, 24); + + CHECK(bl.Apply(ipv4)); + CHECK(bl.Apply(mapped)); +} + +TEST(SocketAddressBlockList, ClearAll) { + SocketAddressBlockList bl; + + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "1.1.1.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 0, &s2); + SocketAddress::ToSockAddr(AF_INET, "192.168.0.0", 0, &s3); + + SocketAddress addr(reinterpret_cast(&s1)); + SocketAddress rangeStart(reinterpret_cast(&s2)); + SocketAddress subnet(reinterpret_cast(&s3)); + + sockaddr_storage s4; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.10", 0, &s4); + SocketAddress rangeEnd(reinterpret_cast(&s4)); + + bl.AddSocketAddress(addr); + bl.AddSocketAddressRange(rangeStart, rangeEnd); + bl.AddSocketAddressMask(subnet, 16); + + CHECK(bl.Apply(addr)); + CHECK(bl.Apply(rangeStart)); + + sockaddr_storage s5; + SocketAddress::ToSockAddr(AF_INET, "192.168.1.1", 0, &s5); + SocketAddress subnetAddr(reinterpret_cast(&s5)); + CHECK(bl.Apply(subnetAddr)); + + bl.Clear(); + + CHECK(!bl.Apply(addr)); + CHECK(!bl.Apply(rangeStart)); + CHECK(!bl.Apply(subnetAddr)); +} + +TEST(SocketAddressBlockList, ParentBlockList) { + auto parent = std::make_shared(); + SocketAddressBlockList child(parent); + + sockaddr_storage s1, s2; + SocketAddress::ToSockAddr(AF_INET, "1.1.1.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "2.2.2.2", 0, &s2); + + SocketAddress addr1(reinterpret_cast(&s1)); + SocketAddress addr2(reinterpret_cast(&s2)); + + parent->AddSocketAddress(addr1); + child.AddSocketAddress(addr2); + + // Child should match both its own rules and parent's. + CHECK(child.Apply(addr1)); + CHECK(child.Apply(addr2)); + + // Parent should only match its own rules. + CHECK(parent->Apply(addr1)); + CHECK(!parent->Apply(addr2)); +} + +TEST(SocketAddressBlockList, SubnetOverlapRemoval) { + // Removing a broader subnet must restore narrower subnets that were + // subsumed by the broader prefix in the trie. + SocketAddressBlockList bl; + + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.0", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "10.1.0.0", 0, &s2); + SocketAddress::ToSockAddr(AF_INET, "10.1.2.3", 0, &s3); + + SocketAddress broad(reinterpret_cast(&s1)); + SocketAddress narrow(reinterpret_cast(&s2)); + SocketAddress target(reinterpret_cast(&s3)); + + bl.AddSocketAddressMask(broad, 8); // 10.0.0.0/8 + bl.AddSocketAddressMask(narrow, 16); // 10.1.0.0/16 (subsumed by /8) + + CHECK(bl.Apply(target)); // Covered by /8. + + bl.RemoveSocketAddressMask(broad, 8); + + // After removing /8, the /16 must still work. + CHECK(bl.Apply(target)); + + // Address outside /16 but inside old /8 should no longer match. + sockaddr_storage s4; + SocketAddress::ToSockAddr(AF_INET, "10.2.0.1", 0, &s4); + SocketAddress outside(reinterpret_cast(&s4)); + CHECK(!bl.Apply(outside)); +} diff --git a/test/parallel/test-blocklist-fast-api.js b/test/parallel/test-blocklist-fast-api.js new file mode 100644 index 000000000000..34ae5d258d0c --- /dev/null +++ b/test/parallel/test-blocklist-fast-api.js @@ -0,0 +1,35 @@ +// Flags: --allow-natives-syntax --expose-internals --no-warnings +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { BlockList } = require('net'); +const { internalBinding } = require('internal/test/binding'); + +const { SocketAddress } = require('net'); + +const blockList = new BlockList(); +blockList.addAddress('1.1.1.1'); +blockList.addSubnet('10.0.0.0', 24); + +// FastCheck requires SocketAddress objects, not strings. +// Strings go through the checkString path instead. +const addr1 = new SocketAddress({ address: '1.1.1.1' }); +const addr2 = new SocketAddress({ address: '2.2.2.2' }); +const addr3 = new SocketAddress({ address: '10.0.0.5' }); + +function testFastCheck() { + assert(blockList.check(addr1)); + assert(!blockList.check(addr2)); + assert(blockList.check(addr3)); +} + +eval('%PrepareFunctionForOptimization(testFastCheck)'); +testFastCheck(); +eval('%OptimizeFunctionOnNextCall(testFastCheck)'); +testFastCheck(); + +if (common.isDebug) { + const { getV8FastApiCallCount } = internalBinding('debug'); + assert.strictEqual(getV8FastApiCallCount('blocklist.check'), 3); +} diff --git a/test/parallel/test-blocklist.js b/test/parallel/test-blocklist.js index 6895efcc1c00..dc5a209aeec5 100644 --- a/test/parallel/test-blocklist.js +++ b/test/parallel/test-blocklist.js @@ -178,11 +178,11 @@ const util = require('util'); blockList.addSubnet('8592:757c:efae:4e45::', 64, 'IpV6'); // Case insensitive const rulesCheck = [ + 'Address: IPv4 1.1.1.1', 'Subnet: IPv6 8592:757c:efae:4e45::/64', 'Range: IPv4 10.0.0.1-10.0.0.10', - 'Address: IPv4 1.1.1.1', ]; - assert.deepStrictEqual(blockList.rules, rulesCheck); + assert.deepStrictEqual(blockList.rules.sort(), rulesCheck.sort()); assert(blockList.check('1.1.1.1')); assert(blockList.check('10.0.0.5')); @@ -288,6 +288,77 @@ const util = require('util'); assert(!BlockList.isBlockList({})); } +{ + // Test that adding the same address twice does not create duplicate rules. + // Previously, the second add would orphan the first rule in the internal + // list while overwriting its index entry, making it unreachable for removal + // but still evaluated during checks. + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + blockList.addAddress('1.1.1.1'); + + // Should have exactly one rule, not two. + assert.strictEqual(blockList.rules.length, 1); + assert(blockList.check('1.1.1.1')); +} + +{ + // Test clear() removes all rules. + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + blockList.addRange('10.0.0.1', '10.0.0.10'); + blockList.addSubnet('192.168.0.0', 16); + + assert.strictEqual(blockList.rules.length, 3); + assert(blockList.check('1.1.1.1')); + assert(blockList.check('10.0.0.5')); + assert(blockList.check('192.168.1.1')); + + blockList.clear(); + + assert.strictEqual(blockList.rules.length, 0); + assert(!blockList.check('1.1.1.1')); + assert(!blockList.check('10.0.0.5')); + assert(!blockList.check('192.168.1.1')); + + // Can add new rules after clearing. + blockList.addAddress('2.2.2.2'); + assert.strictEqual(blockList.rules.length, 1); + assert(blockList.check('2.2.2.2')); + assert(!blockList.check('1.1.1.1')); +} + +{ + // Test addAddresses() batch insert. + const blockList = new BlockList(); + blockList.addAddresses(['1.1.1.1', '2.2.2.2', '3.3.3.3']); + + assert(blockList.check('1.1.1.1')); + assert(blockList.check('2.2.2.2')); + assert(blockList.check('3.3.3.3')); + assert(!blockList.check('4.4.4.4')); + assert.strictEqual(blockList.rules.length, 3); + + // Cross-family works with batch insert. + assert(blockList.check('::ffff:1.1.1.1', 'ipv6')); + + // Batch with SocketAddress objects. + const blockList2 = new BlockList(); + const sa1 = new SocketAddress({ address: '10.0.0.1' }); + const sa2 = new SocketAddress({ address: '10.0.0.2' }); + blockList2.addAddresses([sa1, sa2]); + assert(blockList2.check('10.0.0.1')); + assert(blockList2.check('10.0.0.2')); + assert(!blockList2.check('10.0.0.3')); + + // IPv6 batch. + const blockList3 = new BlockList(); + blockList3.addAddresses(['::1', '::2'], 'ipv6'); + assert(blockList3.check('::1', 'ipv6')); + assert(blockList3.check('::2', 'ipv6')); + assert(!blockList3.check('::3', 'ipv6')); +} + // Test exporting and importing the rule list to/from JSON { const ruleList = [ @@ -359,3 +430,489 @@ const util = require('util'); assert.strictEqual(test5.check(i[0], i[1]), i[2]); }); } + +// removeRange: basic removal +{ + const blockList = new BlockList(); + blockList.addRange('10.0.0.1', '10.0.0.100'); + blockList.addRange('192.168.1.1', '192.168.1.50'); + assert(blockList.check('10.0.0.50')); + assert(blockList.check('192.168.1.25')); + + blockList.removeRange('10.0.0.1', '10.0.0.100'); + assert(!blockList.check('10.0.0.50')); + assert(blockList.check('192.168.1.25')); + assert.strictEqual(blockList.rules.length, 1); +} + +// removeRange: non-existent range is a no-op +{ + const blockList = new BlockList(); + blockList.addRange('10.0.0.1', '10.0.0.10'); + assert.strictEqual(blockList.rules.length, 1); + blockList.removeRange('99.99.99.1', '99.99.99.10'); + assert.strictEqual(blockList.rules.length, 1); + assert(blockList.check('10.0.0.5')); +} + +// removeRange: IPv6 range +{ + const blockList = new BlockList(); + blockList.addRange('2001:db8::1', '2001:db8::ff', 'ipv6'); + assert(blockList.check('2001:db8::50', 'ipv6')); + + blockList.removeRange('2001:db8::1', '2001:db8::ff', 'ipv6'); + assert(!blockList.check('2001:db8::50', 'ipv6')); + assert.strictEqual(blockList.rules.length, 0); +} + +// removeRange: with SocketAddress objects +{ + const blockList = new BlockList(); + const start = new SocketAddress({ address: '10.0.0.1' }); + const end = new SocketAddress({ address: '10.0.0.10' }); + blockList.addRange(start, end); + assert(blockList.check('10.0.0.5')); + + blockList.removeRange(start, end); + assert(!blockList.check('10.0.0.5')); +} + +// removeSubnet: basic IPv4 removal +{ + const blockList = new BlockList(); + blockList.addSubnet('10.0.0.0', 8); + blockList.addSubnet('192.168.0.0', 16); + assert(blockList.check('10.1.2.3')); + assert(blockList.check('192.168.5.5')); + + blockList.removeSubnet('10.0.0.0', 8); + assert(!blockList.check('10.1.2.3')); + assert(blockList.check('192.168.5.5')); + assert.strictEqual(blockList.rules.length, 1); +} + +// removeSubnet: IPv6 +{ + const blockList = new BlockList(); + blockList.addSubnet('2001:db8::', 32, 'ipv6'); + assert(blockList.check('2001:db8::1', 'ipv6')); + + blockList.removeSubnet('2001:db8::', 32, 'ipv6'); + assert(!blockList.check('2001:db8::1', 'ipv6')); + assert.strictEqual(blockList.rules.length, 0); +} + +// removeSubnet: cross-family cleanup +{ + const blockList = new BlockList(); + blockList.addSubnet('10.0.0.0', 8); + assert(blockList.check('::ffff:10.0.0.1', 'ipv6')); + + blockList.removeSubnet('10.0.0.0', 8); + assert(!blockList.check('10.0.0.1')); + assert(!blockList.check('::ffff:10.0.0.1', 'ipv6')); +} + +// removeSubnet: non-existent subnet is a no-op +{ + const blockList = new BlockList(); + blockList.addSubnet('10.0.0.0', 8); + blockList.removeSubnet('172.16.0.0', 12); + assert(blockList.check('10.1.2.3')); + assert.strictEqual(blockList.rules.length, 1); +} + +// removeSubnet: with SocketAddress objects +{ + const blockList = new BlockList(); + const net = new SocketAddress({ address: '10.0.0.0' }); + blockList.addSubnet(net, 8); + assert(blockList.check('10.1.2.3')); + + blockList.removeSubnet(net, 8); + assert(!blockList.check('10.1.2.3')); +} + +// removeRange/removeSubnet don't affect other rule types +{ + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + blockList.addRange('10.0.0.1', '10.0.0.100'); + blockList.addSubnet('192.168.0.0', 16); + + blockList.removeRange('10.0.0.1', '10.0.0.100'); + blockList.removeSubnet('192.168.0.0', 16); + + // Address rule should still work + assert(blockList.check('1.1.1.1')); + assert(!blockList.check('10.0.0.50')); + assert(!blockList.check('192.168.1.1')); +} + +// addCIDR: IPv4 +{ + const blockList = new BlockList(); + blockList.addCIDR('10.0.0.0/8'); + blockList.addCIDR('192.168.1.0/24'); + assert(blockList.check('10.1.2.3')); + assert(blockList.check('192.168.1.50')); + assert(!blockList.check('192.168.2.1')); + assert(!blockList.check('11.0.0.1')); +} + +// addCIDR: IPv6 auto-detected +{ + const blockList = new BlockList(); + blockList.addCIDR('2001:db8::/32'); + assert(blockList.check('2001:db8::1', 'ipv6')); + assert(blockList.check('2001:db8:ffff::1', 'ipv6')); + assert(!blockList.check('2001:db9::1', 'ipv6')); +} + +// addCIDR: cross-family +{ + const blockList = new BlockList(); + blockList.addCIDR('10.0.0.0/8'); + assert(blockList.check('::ffff:10.0.0.1', 'ipv6')); +} + +// addCIDR: validation errors +{ + const blockList = new BlockList(); + assert.throws(() => blockList.addCIDR('10.0.0.0'), { + code: 'ERR_INVALID_ARG_VALUE', + }); + assert.throws(() => blockList.addCIDR('10.0.0.0/abc'), { + code: 'ERR_INVALID_ARG_VALUE', + }); + assert.throws(() => blockList.addCIDR(123), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => blockList.addCIDR('10.0.0.0/'), { + code: 'ERR_INVALID_ARG_VALUE', + }); +} + +// removeCIDR: basic +{ + const blockList = new BlockList(); + blockList.addCIDR('10.0.0.0/8'); + blockList.addCIDR('192.168.0.0/16'); + assert(blockList.check('10.1.2.3')); + + blockList.removeCIDR('10.0.0.0/8'); + assert(!blockList.check('10.1.2.3')); + assert(blockList.check('192.168.1.1')); + assert.strictEqual(blockList.rules.length, 1); +} + +// removeCIDR: IPv6 +{ + const blockList = new BlockList(); + blockList.addCIDR('2001:db8::/32'); + assert(blockList.check('2001:db8::1', 'ipv6')); + + blockList.removeCIDR('2001:db8::/32'); + assert(!blockList.check('2001:db8::1', 'ipv6')); + assert.strictEqual(blockList.rules.length, 0); +} + +// removeCIDR: non-existent is a no-op +{ + const blockList = new BlockList(); + blockList.addCIDR('10.0.0.0/8'); + blockList.removeCIDR('172.16.0.0/12'); + assert(blockList.check('10.1.2.3')); + assert.strictEqual(blockList.rules.length, 1); +} + +// addCIDR interoperates with removeSubnet, and vice versa +{ + const blockList = new BlockList(); + blockList.addCIDR('10.0.0.0/8'); + blockList.removeSubnet('10.0.0.0', 8); + assert(!blockList.check('10.1.2.3')); + + blockList.addSubnet('192.168.0.0', 16); + blockList.removeCIDR('192.168.0.0/16'); + assert(!blockList.check('192.168.1.1')); +} + +// removeAddress: basic +{ + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + blockList.addAddress('2.2.2.2'); + assert(blockList.check('1.1.1.1')); + + blockList.removeAddress('1.1.1.1'); + assert(!blockList.check('1.1.1.1')); + assert(blockList.check('2.2.2.2')); +} + +// removeAddress: cross-family cleanup +{ + const blockList = new BlockList(); + blockList.addAddress('3.3.3.3'); + assert(blockList.check('::ffff:3.3.3.3', 'ipv6')); + + blockList.removeAddress('3.3.3.3'); + assert(!blockList.check('3.3.3.3')); + assert(!blockList.check('::ffff:3.3.3.3', 'ipv6')); +} + +// removeAddress: IPv6 +{ + const blockList = new BlockList(); + blockList.addAddress('::1', 'ipv6'); + assert(blockList.check('::1', 'ipv6')); + + blockList.removeAddress('::1', 'ipv6'); + assert(!blockList.check('::1', 'ipv6')); +} + +// removeAddress: non-existent is a no-op +{ + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + blockList.removeAddress('9.9.9.9'); + assert(blockList.check('1.1.1.1')); +} + +// removeAddress: with SocketAddress object +{ + const blockList = new BlockList(); + const addr = new SocketAddress({ address: '5.5.5.5' }); + blockList.addAddress(addr); + assert(blockList.check('5.5.5.5')); + + blockList.removeAddress(addr); + assert(!blockList.check('5.5.5.5')); +} + +// addCIDRs: batch +{ + const blockList = new BlockList(); + blockList.addCIDRs(['10.0.0.0/8', '192.168.0.0/16', '2001:db8::/32']); + assert(blockList.check('10.1.2.3')); + assert(blockList.check('192.168.1.1')); + assert(blockList.check('2001:db8::1', 'ipv6')); + assert(!blockList.check('11.0.0.1')); + assert.strictEqual(blockList.rules.length, 3); +} + +// addCIDRs: validation +{ + const blockList = new BlockList(); + assert.throws(() => blockList.addCIDRs('not-an-array'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => blockList.addCIDRs([123]), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => blockList.addCIDRs(['10.0.0.0']), { + code: 'ERR_INVALID_ARG_VALUE', + }); +} + +// addCIDRs: empty array is a no-op +{ + const blockList = new BlockList(); + blockList.addCIDRs([]); + assert.strictEqual(blockList.size, 0); +} + +// addCIDRs: invalid entry mid-array does not half-apply +{ + const blockList = new BlockList(); + assert.throws(() => blockList.addCIDRs(['10.0.0.0/8', 'bad', '1.1.1.0/24']), { + code: 'ERR_INVALID_ARG_VALUE', + }); + // Nothing should have been applied. + assert.strictEqual(blockList.size, 0); + assert(!blockList.check('10.0.0.1')); +} + +// size: tracks all rule types +{ + const blockList = new BlockList(); + assert.strictEqual(blockList.size, 0); + + blockList.addAddress('1.1.1.1'); + assert.strictEqual(blockList.size, 1); + + blockList.addRange('10.0.0.1', '10.0.0.10'); + assert.strictEqual(blockList.size, 2); + + blockList.addSubnet('192.168.0.0', 16); + assert.strictEqual(blockList.size, 3); + + // Matches rules.length + assert.strictEqual(blockList.size, blockList.rules.length); + + blockList.removeAddress('1.1.1.1'); + assert.strictEqual(blockList.size, 2); + + blockList.removeRange('10.0.0.1', '10.0.0.10'); + assert.strictEqual(blockList.size, 1); + + blockList.removeSubnet('192.168.0.0', 16); + assert.strictEqual(blockList.size, 0); + + // After clear + blockList.addAddress('5.5.5.5'); + blockList.clear(); + assert.strictEqual(blockList.size, 0); +} + +// size: duplicate addAddress does not double-count +{ + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + blockList.addAddress('1.1.1.1'); + assert.strictEqual(blockList.size, 1); +} + +// PRIVATE_RANGES: is a frozen array of CIDR strings +{ + assert(Array.isArray(BlockList.PRIVATE_RANGES)); + assert(Object.isFrozen(BlockList.PRIVATE_RANGES)); + assert(BlockList.PRIVATE_RANGES.length > 0); + for (const cidr of BlockList.PRIVATE_RANGES) { + assert.strictEqual(typeof cidr, 'string'); + assert(cidr.includes('/')); + } +} + +// PRIVATE_RANGES: covers expected addresses +{ + const blockList = new BlockList(); + blockList.addCIDRs(BlockList.PRIVATE_RANGES); + + // IPv4 private (RFC 1918) + assert(blockList.check('10.0.0.1')); + assert(blockList.check('10.255.255.255')); + assert(blockList.check('172.16.0.1')); + assert(blockList.check('172.31.255.255')); + assert(blockList.check('192.168.0.1')); + assert(blockList.check('192.168.255.255')); + + // Loopback + assert(blockList.check('127.0.0.1')); + assert(blockList.check('127.255.255.255')); + assert(blockList.check('::1', 'ipv6')); + + // Link-local + assert(blockList.check('169.254.0.1')); + assert(blockList.check('fe80::1', 'ipv6')); + + // ULA + assert(blockList.check('fc00::1', 'ipv6')); + assert(blockList.check('fd00::1', 'ipv6')); + + // Public addresses should not match + assert(!blockList.check('8.8.8.8')); + assert(!blockList.check('1.1.1.1')); + assert(!blockList.check('203.0.113.1')); + assert(!blockList.check('2001:db8::1', 'ipv6')); +} + +// check() with invalid address string returns false (exercises checkString +// error path in C++ — SocketAddress::New fails, returns false). +{ + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + assert.strictEqual(blockList.check('not_a_valid_ip'), false); + assert.strictEqual(blockList.check('', 'ipv4'), false); + assert.strictEqual(blockList.check('999.999.999.999'), false); + assert.strictEqual(blockList.check('not_valid_ipv6', 'ipv6'), false); +} + +// check() family parameter is case-insensitive. +{ + const blockList = new BlockList(); + blockList.addAddress('10.0.0.1'); + blockList.addAddress('::1', 'ipv6'); + + assert(blockList.check('10.0.0.1', 'ipv4')); + assert(blockList.check('10.0.0.1', 'IPv4')); + assert(blockList.check('10.0.0.1', 'IPV4')); + assert(blockList.check('::1', 'ipv6')); + assert(blockList.check('::1', 'IPv6')); + assert(blockList.check('::1', 'IPV6')); +} + +// SocketAddress constructor with invalid address throws ERR_INVALID_ADDRESS. +{ + assert.throws(() => new SocketAddress({ address: 'not_a_valid_ip' }), { + code: 'ERR_INVALID_ADDRESS', + }); + assert.throws( + () => new SocketAddress({ address: 'not_valid', family: 'ipv6' }), { + code: 'ERR_INVALID_ADDRESS', + }); +} + +// check() with SocketAddress objects across family boundaries. +{ + const blockList = new BlockList(); + const ipv4 = new SocketAddress({ address: '10.0.0.1' }); + const mapped = new SocketAddress({ + address: '::ffff:10.0.0.1', + family: 'ipv6', + }); + + blockList.addAddress(ipv4); + + // Check with SocketAddress objects (exercises the check() -> C++ fast path). + assert(blockList.check(ipv4)); + assert(blockList.check(mapped)); + + blockList.removeAddress(ipv4); + assert(!blockList.check(ipv4)); + assert(!blockList.check(mapped)); +} + +// Subnet with IPv4-mapped IPv6 network. +{ + const blockList = new BlockList(); + blockList.addSubnet('::ffff:10.0.0.0', 120, 'ipv6'); + + // IPv4-mapped IPv6 within the subnet should match. + assert(blockList.check('::ffff:10.0.0.5', 'ipv6')); + // The plain IPv4 form should also match (cross-family trie lookup). + assert(blockList.check('10.0.0.5')); + // Outside the subnet. + assert(!blockList.check('10.0.1.0')); +} + +// Range with IPv6 addresses. +{ + const blockList = new BlockList(); + blockList.addRange('::1', '::ff', 'ipv6'); + assert(blockList.check('::1', 'ipv6')); + assert(blockList.check('::a0', 'ipv6')); + assert(blockList.check('::ff', 'ipv6')); + assert(!blockList.check('::100', 'ipv6')); + assert(!blockList.check('::0', 'ipv6')); + + blockList.removeRange('::1', '::ff', 'ipv6'); + assert(!blockList.check('::a0', 'ipv6')); +} + +// Removing a broader subnet must restore subsumed narrower subnets. +{ + const blockList = new BlockList(); + blockList.addSubnet('10.0.0.0', 8); // /8 subsumes /16 in the trie + blockList.addSubnet('10.1.0.0', 16); + assert(blockList.check('10.1.2.3')); + + blockList.removeSubnet('10.0.0.0', 8); + + // /16 must still work after /8 is removed. + assert(blockList.check('10.1.2.3')); + // Address outside /16 but inside old /8 should no longer match. + assert(!blockList.check('10.2.0.1')); + assert.strictEqual(blockList.rules.length, 1); +}