11

Is there a good way to match an IPv6 address to an IPv6 subnet using CIDR notation? What I am looking for is the IPv6 equivalent to this: Matching an IP to a CIDR mask in PHP 5?

The example given above can't be used since an IPv6 address is 128 bits long, preventing the bitwise left-shift from working properly. Can you think of any other way?

EDIT: Added my own solution to the list of answers.

Community
  • 1
  • 1
MW.
  • 12,550
  • 9
  • 36
  • 65

7 Answers7

18

Since you cannot convert IPv6 addresses to integer, you should operate bits, like this:

$ip='21DA:00D3:0000:2F3B:02AC:00FF:FE28:9C5A';
$cidrnet='21DA:00D3:0000:2F3B::/64';

// converts inet_pton output to string with bits
function inet_to_bits($inet) 
{
   $splitted = str_split($inet);
   $binaryip = '';
   foreach ($splitted as $char) {
             $binaryip .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT);
   }
   return $binaryip;
}    

$ip = inet_pton($ip);
$binaryip=inet_to_bits($ip);

list($net,$maskbits)=explode('/',$cidrnet);
$net=inet_pton($net);
$binarynet=inet_to_bits($net);

$ip_net_bits=substr($binaryip,0,$maskbits);
$net_bits   =substr($binarynet,0,$maskbits);

if($ip_net_bits!==$net_bits) echo 'Not in subnet';
else echo 'In subnet';

Also, if you use some database to store IPs, it may already have all the functions to compare them. For example, Postgres has an inet type and can determine, whether IP is contained within subnet like this:

SELECT 
   '21DA:00D3:0000:2F3B:02AC:00FF:FE28:9C5A'::inet << 
   '21DA:00D3:0000:2F3B::/64'::inet;

9.11. Network Address Functions and Operators in PostgreSQL

DysphoricUnicorn
  • 495
  • 7
  • 16
Snifff
  • 1,784
  • 2
  • 16
  • 28
  • 2
    Nice - I never considered binary strings for some reason. – MW. Oct 31 '11 at 10:41
  • "you cannot convert IPv6 addresses to integer". I'm guessing this is because they're 128 bits, and integers are limited to 32 or 64 bits? – Sean the Bean Mar 20 '18 at 15:39
  • 1
    I didn't understood the unpack call. In my case it seems to work better without it. The net mask often ends with 0's and they are removed. so `foreach (str_split($inet) as $char)` seems to be correct – iRaS May 16 '20 at 18:24
8

You can also use the IpUtils class from symfony/http-foundation package:

IpUtils::checkIp6('2a01:8760:2:3001::1', '2a01:8760:2:3001::1/64')

This will check the IPv6 validity and range match. Will return false if it's not the case.

Soullivaneuh
  • 3,553
  • 3
  • 37
  • 71
5

PHP can perform bitwise operations on strings!

  • IPv4 or IPv6
  • No base conversion
  • No ASCII bit strings
  • Pretty fast
<?php

/**
 * Does the given IP match the CIDR prefix?
 */
function matchIp(string $ip, string $cidr): bool
{
  // Get mask bits
  list($net, $maskBits) = explode('/', $cidr);

  // Size
  $size = (strpos($ip, ':') === false) ? 4 : 16;

  // Convert to binary
  $ip = inet_pton($ip);
  $net = inet_pton($net);
  if (!$ip || !$net) {
    throw new InvalidArgumentException('Invalid IP address');
  }

  // Build mask
  $solid = floor($maskBits / 8);
  $solidBits = $solid * 8;
  $mask = str_repeat(chr(255), $solid);
  for ($i = $solidBits; $i < $maskBits; $i += 8) {
    $bits = max(0, min(8, $maskBits - $i));
    $mask .= chr((pow(2, $bits) - 1) << (8 - $bits));
  }
  $mask = str_pad($mask, $size, chr(0));

  // Compare the mask
  return ($ip & $mask) === ($net & $mask);
}

jchook
  • 6,690
  • 5
  • 38
  • 40
4

I created my own solution, using the following code:

function iPv6MaskToByteArray($subnetMask) {
  $addr = str_repeat("f", $subnetMask / 4);
  switch ($subnetMask % 4) {
    case 0:
      break;
    case 1:
      $addr .= "8";
      break;
    case 2:
      $addr .= "c";
      break;
    case 3:
      $addr .= "e";
      break;
  }
  $addr = str_pad($addr, 32, '0');
  $addr = pack("H*" , $addr);
  return $addr;
}

function iPv6CidrMatch($address, $subnetAddress, $subnetMask) {
  $binMask = iPv6MaskToByteArray($subnetMask);
  return ($address & $binMask) == $subnetAddress;
}

Note that $address and $subnetAddress were obtained by running the string address through inet_pton. Call the function as follows:

$subnet = inet_pton("2001:06b8::");
$mask = 32;
$addr = inet_pton("2001:06b8:0000:0000:0000:0000:1428:07ab");
$match = iPv6CidrMatch($addr, $subnet, $mask); // TRUE
MW.
  • 12,550
  • 9
  • 36
  • 65
3

Here is an example that works by checking an IP address against a list of individual IPs or CIDRs, both IPv4 and IPv6:

https://gist.github.com/lyquix-owner/2620da22d927c99d57555530aab3279b

<?php
// IP to check
$ip_check = $_SERVER['REMOTE_ADDR'];

// Array of allowed IPs and subnets, both IPv4 and IPv6
$ips_allowed = array(
    '192.30.252.0/22',
    '2620:112:3000::/44',
    '192.168.16.104'
);

// Flag for IP match allowed list
$allow = false;

foreach($ips_allowed as $ip_allow) {
    // If IP has / means CIDR notation
    if(strpos($ip_allow, '/') === false) {
        // Check Single IP
        if(inet_pton($ip_check) == inet_pton($ip_allow)) {
            $allow = true;
            break;
        }
    }
    else {
        // Check IP range
        list($subnet, $bits) = explode('/', $ip_allow);
        
        // Convert subnet to binary string of $bits length
        $subnet = unpack('H*', inet_pton($subnet)); // Subnet in Hex
        foreach($subnet as $i => $h) $subnet[$i] = base_convert($h, 16, 2); // Array of Binary
        $subnet = substr(implode('', $subnet), 0, $bits); // Subnet in Binary, only network bits
        
        // Convert remote IP to binary string of $bits length
        $ip = unpack('H*', inet_pton($ip_check)); // IP in Hex
        foreach($ip as $i => $h) $ip[$i] = base_convert($h, 16, 2); // Array of Binary
        $ip = substr(implode('', $ip), 0, $bits); // IP in Binary, only network bits
        
        // Check network bits match
        if($subnet == $ip) {
            $allow = true;
            break;
        }
    }
}
if(!$allow) {
    die('IP not allowed');
}
a55
  • 376
  • 3
  • 13
Lyquix
  • 31
  • 1
  • This answer does a bit more than what is asked by the OP but is full-featured. – Mathieu Dubois Aug 01 '19 at 09:58
  • 1
    Also `base_convert` not works for IPv6 because of [This](https://stackoverflow.com/a/18843167) and [This](https://www.php.net/manual/en/function.base-convert.php#51077) – a55 Oct 11 '21 at 05:25
1

If your masks are always divisible by four (which is quite common in ipv6). You can use:

function checkIPv6WithinRange($ipv6, $range) {
    list ($net, $mask) = preg_split("/\//", $range);

    if ($mask % 4)
        throw new NotImplementedException("Only masks divisible by 4 are supported");
    $stripChars = (128-$mask)/4;

    $hexNet = bin2hex(inet_pton($net));
    $reducedNet = substr($hexNet, 0, 0 - $stripChars);

    $hexIp = bin2hex(inet_pton($ipv6));
    $reducedIp = substr($hexIp, 0, 0 - $stripChars);

    return $reducedIp === $reducedNet;
}
Petr Sýkora
  • 114
  • 5
0

Basically http://www.phpclasses.org/browse/file/70429.html

To use it, simply call

$cidr = new CIDR();
$cidr->match($ipv6, $ipv6_in_cidr);

Result is 'Good'.