Validate IP Address Network Inclusion

To be able to validate if a given IP address belongs within a specific network, or address range, we will need some basic understanding of binary numbers and networking. For the sake of scoping I have completely disregard IPv6 addresses, focusing entirely on IPv4. A lot of the intricacies of IPv4 will be surely be skipped as well. Feel free to reach out if I have missed something essential!

Without further ado, let’s get acquainted with some of the concepts.

The Address and the Block

An IP address is commonly recognised as “those numbers with dots”, say 192.169.0.1, that you need in order to reach your home router. Actually, the dots are not super important for anything other than making an address more readable to humans. I like to use the following, gross oversimplification, to reason about addresses: imagine that an IP address is the computer equivalent of a street address, Network Street 14. Though, instead of a building, you will find a computer, or host.

A CIDR block in the other hand is a range of IP addresses available in a network. Expanding on the metaphor above, a CIDR block would be the entire street, Network Street. That’s it! A block is communicated using the format ip address/network mask, i.e. 192.168.0.0/16.

Classless inter-domain routing, CIDR, was introduced as a way to postpone the exhaustion of IPv4 addresses by enabling variable range reservations. If you are interested in the theory behind it I suggest reading RFC 1338 where the idea was formally proposed.x

In order to implement the validation we are after, two facts are important to know:

  • An IP address is roughly made up of two parts of variable length: a network adress and a host adress. Again, using our previous example, the network address would be Network Street while the host address would be 14.
  • Using the netmask we can derive all possible host addresses in a network range, or CIDR block.

Ones and Zeroes

I previously hinted that the format of an IP address is somewhat unimportant and mostly for humans. What I meant by that is, as with everything computers, in the end, an IP address is transmitted in binary using pulses of things like electricity, light or radio waves. This fundament is really what the internet is designed around, and we need to understand it to some extent to be able to do our validation using arithmetic.

The fact that we are able to convert ones and zeroes into everything digitally imaginable is still pretty mind blowing to me. Humans are amazing, and speaking of, @fasterthanlime made an amazing video on how the internet works.

To clarify, let’s visualise 192.168.0.1, commonly assigned to home routers, in binary.

   192    .    168    .     0     .     1
1100 0000 . 1010 1000 . 0000 0000 . 0000 0001

Decimal (or base 10) numbers are chosen as representation because it is what we are used to working with, but at the lower level we are working with 32 bits.

Now, let’s break down the CIDR block 192.168.0.0/16 that defines a network that includes the IP address 192.168.1.1.

More exactly the /16 part which specifies the netmask, also known as network mask or subnet mask, detailing the amount of leading ones in the 32-bit binary representation. Another common approach to is to display it in the same format as IP addresses. Let’s take a look at how the mask /16 looks like in both binary and decimal form.

1111 1111 . 1111 1111 . 0000 0000 . 0000 0000
   255    .    255    .     0     .     0

Now that we (kind of) know all this, we are actually able to mathematically validate if an IP address belongs to a specific network with the help of bitwise operators. Hooray!

Implementation

The plan looks a little something like this:

  1. Convert network mask to an integer.
  2. Convert both the candidate and CIDR block IP addresses to unsigned integers.
  3. Compare results of bitwise AND operations (&) for candidate and mask with the result of the CIDR address and mask and return the outcome.

Doesn’t sound too difficult when broken down like that, does it? Let’s get coding!

For absolutely no reason other than it being slightly inappropriate will I be using TypeScript in the following examples! 🥳

function isIpv4InCidr(candidate: string, cidr: string): boolean {
 const [address, mask] = cidr.split("/");
}

Great start! TypeScript is mad as heck and someone is going to complain about the lack of comments describing the expected notations in the fictional pull request, but this definitely deserves a coffee break… At least some water cooler talk!

On to something slightly more difficult: converting mask to an unsigned integer.

Making Out the Mask

With enough understanding of binary we could opt for something as simple as the following.

parseInt("".padEnd(parseInt(mask, 10), "1").padEnd(32, "0"), 2);
// output: 4294901760

It works, but it’s neither elegant nor very optimised. We can do better! A common approach in writing efficient algorithms is searching for a mathematical solution. Imagine that we are still working with a network mask of 16. Looking, once again, at the binary representation, our desired output is as follows:

1111 1111 1111 1111 0000 0000 0000 0000

I have grouped the bits into nibbles (groups of four) for readability only. There is no other significance to the formatting.

Something neat about binary is that we can real easily place a 1 in the n:th bit using the formula $2 ^{(n - 1)}$.

2 ** (16 - 1);
// output: 32768 (decimal)
// equal to: 0000 0000 0000 0000 1000 0000 0000 0000
//                               ^ 16th place

With minor modifications to the formula, making it $2^n-1$, we can just as easily make all bits up to and including n carry the value of one.

2 ** 16 - 1;
// output: 65535
// equal to: 0000 0000 0000 0000 1111 1111 1111 1111

Armed with this knowledge we can easily calculate the reverse of what we want. Luckily, reversing bits is easy using the NOT operator (~). Wrapping up we can remove the signing bit using >>> 0.

This is done primarily for making binary representations a bit more sensible.

function isIpv4InCidr(candidate: string, cidr: string): boolean {
 const [address, mask] = cidr.split("/");
+
+ const uintMask = ~(2 ** (32 - parseInt(mask, 10)) - 1) >>> 0;
}

Seeing a oneliner like that would confuse most people the first time they saw it. Breaking the entire chain of operations down will hopefully shed some light as to what actually happens to the individual bits.

// example when mask is 16
// we subtract the mask value from 32, the total amount of bits

let res = 2 ** (32 - 16);
// output: 0000 0000 0000 0001 0000 0000 0000 0000
//                           ^
//                           17th position -- first one is 2 ** 0!

res -= 1;
// output: 0000 0000 0000 0000 1111 1111 1111 1111

res = ~res;
// output: 0000 0000 0000 00-1 0000 0000 0000 0000
//                          ^
//                          due to res currently being a signed integer

res >>>= 0;
// output: 1111 1111 1111 1111 0000 0000 0000 0000
// after unsigning we get our desired result!

Converting IP Addresses

Converting an IP address to an integer is slightly easier. We split on the dots and use either a simple loop or Array.prototype.reduce() to calculate the integer, shifting the previous sum to the left by eight bits every iteration. Finally we can return the value after removing the signing bit.

function ipv4ToUint(ip: string): number {
  const addressParts = ip.split(".");

  const intAddress = addressParts.reduce(
    (int, part) => (int << 8) + parseInt(part, 10),
    0,
  );

  return intAddress >>> 0;
}

Looking at how int changes after each iteration will make things a bit clearer. I masked all iterations but the first one.

1: ---- ---- ---- ---- ---- ---- 1100 0000 // last eight bits represent 192
2: ---- ---- ---- ---- 1100 0000 ---- ---- // note how they have shifted left
3: ---- ---- 1100 0000 ---- ---- ---- ----
4: 1100 0000 ---- ---- ---- ---- ---- ---- // 192 end up as leftmost eight bits

Cool. Using the new function ipv4ToUint(), we can go on with implementing isIpv4InCidr().

function isIpv4InCidr(candidate: string, cidr: string): boolean {
 const [cidrAddress, mask] = cidr.split("/");

 const uintMask = ~(2 ** (32 - parseInt(mask, 10)) - 1) >>> 0;
+ const uintCidrAddress = ipv4ToUint(cidrAddress);
+ const uintCandidate = ipv4ToUint(candidate);
}

Comparing Outcomes

Finally, with having the network mask, candidate IP address and CIDR IP address all represented as unsigned integers, all that remains is the final step. Code wise it is not difficult to write out.

function isIpv4InCidr(candidate: string, cidr: string): boolean {
  const [cidrAddress, mask] = cidr.split("/");

  const uintMask = ~(2 ** (32 - parseInt(mask, 10)) - 1) >>> 0;
  const uintCidrAddress = ipv4ToUint(cidrAddress);
  const uintCandidate = ipv4ToUint(candidate);

  return (uintCidrAddress & uintMask) === (uintCandidate & uintMask);
}

Done! But what is actually happening here? And how does it prove that an IP is a member of the CIDR block? Taking the candidate 192.168.1.10 and CIDR block 192.168.0.0/16 as argument examples, we would have binary representations of the unsigned integers as well as operation results looking like this:

┌─────────────────┬─────────────────────────────────────────┐
 uintMask & uintCidrAddress
├─────────────────┼─────────────────────────────────────────┤
        uintMask 1111 1111 1111 1111 0000 0000 0000 0000
 uintCidrAddress 1100 0000 1010 1000 0000 0000 0000 0000
          result 1100 0000 1010 1000 0000 0000 0000 0000
└─────────────────┴─────────────────────────────────────────┘
┌─────────────────┬─────────────────────────────────────────┐
 uintMask & uintCandidate
├─────────────────┼─────────────────────────────────────────┤
        uintMask 1111 1111 1111 1111 0000 0000 0000 0000
   uintCandidate 1100 0000 1010 1000 0000 0001 0000 1010
          result 1100 0000 1010 1000 0000 0000 0000 0000
└─────────────────┴─────────────────────────────────────────┘

Note that both result are the same for IP addresses within a CIDR block.

Happy coding!