Subnet Membership Checker Script (JavaScript)

This Node.js script checks whether an IP address (IPv4 or IPv6) falls inside any of a list of CIDR blocks, using nothing but BigInt bitwise arithmetic — it makes no network request and calls no API at all, since subnet membership is pure math over data you already have, not a fact any API needs to answer.

How to run this script

placeholder

  1. Download subnet-membership.js below.
  2. Run: node subnet-membership.js 10.0.5.9 10.0.0.0/8 192.168.0.0/16
  3. The script prints every CIDR block from the list the address falls inside, or says it matched none.
  4. require('./subnet-membership.js') also works from your own Node script — see the exported functions below.

Full source code

subnet-membership.js Download subnet-membership.js
// SPDX-License-Identifier: MIT
// subnet-membership.js -- check whether an IP address falls inside any of a
// list of CIDR blocks, entirely with local BigInt arithmetic.
//
// This recipe does NOT call the ip.xakki.pro API at all, or any network
// endpoint -- subnet membership is pure math over an address and a set of
// prefixes you already have, not a fact our (or anyone else's) API needs to
// answer. It is included in this section deliberately, as an honest
// counter-example to the other recipes here: not every IP task is an API
// call, and a tool that fabricates a network round-trip for arithmetic it
// could do locally is worse, not better.
//
// Zero dependencies -- plain Node.js built-ins only (BigInt is a language
// primitive since Node 10.4; no npm install). Works as a CLI script and as
// a Node module (module.exports below, used by this recipe's own
// `node --test` suite, subnet-membership.test.js).
//
// CLI usage:
//   node subnet-membership.js <ip> <cidr1> [cidr2 ...]
//   node subnet-membership.js 10.0.5.9 10.0.0.0/8 192.168.0.0/16
//   node subnet-membership.js 2001:db8::1 2001:db8::/32

'use strict';

// parseIPv4(str) -> BigInt | null -- strict dotted-quad, each octet 0-255,
// exactly 4 octets, no leading-zero ambiguity (leading zeros rejected --
// some stacks read "010" as octal, a classic SSRF-adjacent footgun).
function parseIPv4(str) {
  var parts = str.split('.');
  if (parts.length !== 4) return null;
  var value = 0n;
  for (var i = 0; i < 4; i++) {
    var part = parts[i];
    if (!/^\d{1,3}$/.test(part) || (part.length > 1 && part[0] === '0')) return null;
    var n = Number(part);
    if (n > 255) return null;
    value = (value << 8n) | BigInt(n);
  }
  return value;
}

// parseIPv6(str) -> BigInt | null -- supports "::" compression (at most
// once), 2-8 colon-separated hextets, and rejects malformed input. Does
// NOT support embedded-IPv4 tail notation (e.g. "::ffff:1.2.3.4") -- a
// known, documented limitation (see the FAQ on the page for this recipe).
function parseIPv6(str) {
  if (str.indexOf(':') === -1) return null;
  var doubleColonCount = (str.match(/::/g) || []).length;
  if (doubleColonCount > 1) return null;

  var halves = str.split('::');
  if (halves.length > 2) return null;

  var headParts = halves[0] === '' ? [] : halves[0].split(':');
  var tailParts = halves.length === 2 ? (halves[1] === '' ? [] : halves[1].split(':')) : [];

  if (halves.length === 1) {
    if (headParts.length !== 8) return null;
  } else {
    var missing = 8 - (headParts.length + tailParts.length);
    if (missing < 1) return null;
    var zeros = [];
    for (var z = 0; z < missing; z++) zeros.push('0');
    headParts = headParts.concat(zeros, tailParts);
  }
  if (headParts.length !== 8) return null;

  var value = 0n;
  for (var i = 0; i < 8; i++) {
    if (!/^[0-9a-fA-F]{1,4}$/.test(headParts[i])) return null;
    value = (value << 16n) | BigInt(parseInt(headParts[i], 16));
  }
  return value;
}

// ipToBigInt(str) -> { version: 4 | 6, value: BigInt } | null
function ipToBigInt(str) {
  var v4 = parseIPv4(str);
  if (v4 !== null) return { version: 4, value: v4 };
  var v6 = parseIPv6(str);
  if (v6 !== null) return { version: 6, value: v6 };
  return null;
}

// parseCIDR(str) -> { version, network: BigInt, prefixLen, maxBits } | null
// -- the network address is the input's own address MASKED down to
// prefixLen (so "10.1.2.3/8" behaves like "10.0.0.0/8" for membership,
// matching how routers actually match traffic).
function parseCIDR(str) {
  var slash = str.lastIndexOf('/');
  if (slash === -1) return null;
  var addrPart = str.slice(0, slash);
  var prefixPart = str.slice(slash + 1);
  if (!/^\d{1,3}$/.test(prefixPart)) return null;
  var prefixLen = Number(prefixPart);

  var parsed = ipToBigInt(addrPart);
  if (parsed === null) return null;
  var maxBits = parsed.version === 4 ? 32 : 128;
  if (prefixLen > maxBits) return null;

  var hostBits = BigInt(maxBits - prefixLen);
  var mask = hostBits === 0n ? (2n ** BigInt(maxBits)) - 1n : (((1n << BigInt(prefixLen)) - 1n) << hostBits);
  var network = parsed.value & mask;
  return { version: parsed.version, network: network, prefixLen: prefixLen, maxBits: maxBits };
}

// isInCIDR(ipStr, cidrStr) -> true | false | null (null = either input
// failed to parse -- distinct from a real "not a member" false).
function isInCIDR(ipStr, cidrStr) {
  var ip = ipToBigInt(ipStr);
  var cidr = parseCIDR(cidrStr);
  if (ip === null || cidr === null || ip.version !== cidr.version) return null;
  var hostBits = BigInt(cidr.maxBits - cidr.prefixLen);
  var ipNetworkPart = hostBits === 0n ? ip.value : (ip.value >> hostBits) << hostBits;
  return ipNetworkPart === cidr.network;
}

// findMatchingCIDRs(ipStr, cidrList) -> array of the CIDR strings from
// cidrList that ipStr falls inside. Malformed CIDR entries in the list are
// silently skipped (not a member, not an error) -- callers who need to
// know about a malformed entry should validate the list with parseCIDR
// themselves first.
function findMatchingCIDRs(ipStr, cidrList) {
  var matches = [];
  for (var i = 0; i < cidrList.length; i++) {
    if (isInCIDR(ipStr, cidrList[i]) === true) matches.push(cidrList[i]);
  }
  return matches;
}

function runCLI(argv) {
  if (argv.length < 2) {
    console.error('usage: node subnet-membership.js <ip> <cidr1> [cidr2 ...]');
    process.exitCode = 1;
    return;
  }
  var ipStr = argv[0];
  var cidrList = argv.slice(1);

  if (ipToBigInt(ipStr) === null) {
    console.error('error: "' + ipStr + '" is not a valid IPv4 or IPv6 address');
    process.exitCode = 1;
    return;
  }

  var anyInvalidCIDR = false;
  for (var i = 0; i < cidrList.length; i++) {
    if (parseCIDR(cidrList[i]) === null) {
      console.error('warning: skipping invalid CIDR "' + cidrList[i] + '"');
      anyInvalidCIDR = true;
    }
  }

  var matches = findMatchingCIDRs(ipStr, cidrList);
  if (matches.length === 0) {
    console.log(ipStr + ' is NOT inside any of the given subnets.');
    process.exitCode = anyInvalidCIDR ? 1 : 2;
  } else {
    console.log(ipStr + ' is inside:');
    for (var m = 0; m < matches.length; m++) console.log('  ' + matches[m]);
  }
}

if (require.main === module) {
  runCLI(process.argv.slice(2));
}

module.exports = {
  parseIPv4: parseIPv4,
  parseIPv6: parseIPv6,
  ipToBigInt: ipToBigInt,
  parseCIDR: parseCIDR,
  isInCIDR: isInCIDR,
  findMatchingCIDRs: findMatchingCIDRs
};

This script is MIT-licensed (SPDX-License-Identifier: MIT header at the top of the file) — copy, modify and reuse it freely in your own project, no attribution required.

Subnet Membership Checker Script (JavaScript)

Both IPv4 and IPv6 addresses are converted to a single BigInt (32 bits or 128 bits wide) so that "is this address inside this CIDR" reduces to one bitwise AND against a precomputed mask and a comparison — the same operation a router's forwarding table performs, done here in plain JavaScript with no native/WASM dependency. BigInt (not Number) is required for IPv6, since 128 bits overflows a JS double's 53-bit safe integer range.

Parsing is deliberately strict, not lenient: a leading zero in an IPv4 octet ("010.0.0.1") is rejected outright rather than silently reinterpreted, since some older stacks read a leading zero as octal — exactly the kind of ambiguity that has caused real SSRF bypasses elsewhere. IPv6 "::" compression is supported (at most once, per RFC 5952), but the embedded-IPv4-tail notation ("::ffff:1.2.3.4") is a known, documented limitation — not supported by this recipe.

isInCIDR returns null (not false) when either input fails to parse or the two are different address families — a null-vs-false distinction the CLI surfaces as a warning rather than silently reporting "not a member" for a CIDR entry that was simply malformed. findMatchingCIDRs skips invalid list entries silently for convenience; a caller who needs to know about a malformed entry validates the list with parseCIDR itself first, since not every use of the list wants that noise.

Glossary

BigInt
JavaScript's arbitrary-precision integer type (the 123n literal suffix) — required here because a 128-bit IPv6 address doesn't fit in a regular Number's 53-bit safe integer range.
CIDR notation
Classless Inter-Domain Routing — an address plus a slash and a prefix length (e.g. 10.0.0.0/8), denoting a contiguous block of addresses sharing that many leading bits.
Network address
A CIDR block's address with every host bit forced to zero — "10.1.2.3/8" and "10.0.0.0/8" resolve to the same network address and therefore the same membership set.
"::" compression
The IPv6 shorthand that collapses one run of consecutive all-zero groups to "::", usable at most once per address (RFC 5952) — "2001:db8::1" expands to 8 explicit groups.
Host bits
The trailing bits of an address NOT covered by the prefix length — e.g. the last 24 bits in a /8 — masked to zero when computing a CIDR block's network address.
module.exports (UMD-lite)
The pattern this file uses to be BOTH a standalone Node CLI script (`node subnet-membership.js ...`) and a requireable module for its own node --test suite, without any bundler.

API equivalent

placeholder

Read the full API docs

Frequently asked questions

Why doesn't this recipe call the ip.xakki.pro API like the others?
Because it has nothing to ask it — checking whether an address falls inside a CIDR block you already have is arithmetic, not a lookup. This recipe is included specifically to make that distinction explicit rather than fabricating an API call for a task that doesn't need one.
Does it support "::ffff:1.2.3.4"-style embedded IPv4 addresses?
No — this is a documented limitation. parseIPv6 expects every group to be a plain hex hextet; feed it the address's plain IPv6 or plain IPv4 form instead.
Why BigInt instead of just splitting the address into 4 bytes for IPv4?
Byte-splitting works fine for IPv4 alone, but this script deliberately shares one code path for both families — BigInt is the only JS-native integer type wide enough for a 128-bit IPv6 address, so using it for IPv4 too keeps parseCIDR/isInCIDR identical for both.
What happens if I pass an invalid CIDR in the list?
The CLI prints a warning to stderr and skips it; findMatchingCIDRs (used internally) silently ignores invalid entries so one bad line in a long list doesn't abort the whole check.
Is the network address itself considered a member of its own CIDR block?
Yes, and so is the broadcast-equivalent last address — both boundary addresses of a block are members (see the node --test suite's boundary-address cases for exact examples).

Like this tool? Our API gives free accounts 1,000 requests/day.See the API docs →