Skip to content

Commit

Permalink
Improve IPv4/IPv6 conversion
Browse files Browse the repository at this point in the history
  • Loading branch information
nyamsprod committed Jul 26, 2024
1 parent 3f3577f commit 9e84eb0
Show file tree
Hide file tree
Showing 9 changed files with 148 additions and 56 deletions.
2 changes: 1 addition & 1 deletion components/ModifierTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -883,7 +883,7 @@ public static function ipv6NormalizationUriProvider(): iterable
yield 'no change happen with a IPv4 host' => [
'inputUri' => 'https://127.0.0.1/foo/bar',
'compressedUri' => 'https://127.0.0.1/foo/bar',
'expandedUri' => 'https://[0000:0000:0000:0000:0000:ffff:7f00:0001]/foo/bar',
'expandedUri' => 'https://127.0.0.1/foo/bar',
];

yield 'IPv6 gets expanded if needed' => [
Expand Down
14 changes: 14 additions & 0 deletions docs/interfaces/7.0/ipv4.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ $converter->toOctal('0xc0a821'); // returns "0300.0250.0002.0001"
$converter->toHexadecimal('192.168.2.1.'); // returns "0xc0a821"
```

<p class="message-notice">since version <code>7.5.0</code></p>

The `toDecimal` method will also infer the IPv4 address out of an IPv4 mapped IPv6 address or
from a 6to4 notation and two (2) new dedicated method allow to convert your IPv4 address into
either its IPv4 mapped IPv6 address representation or its 6to4 notation.

```php
$converter = Converter::fromEnvironment();
$converter->toDecimal('[2002:0000:0000::]') // returns 0.0.0.0
$converter->toIPv4MapedIPv6('0xc0a821'); // returns "[::ffff:192.168.2.1]"
$converter->to6to4('0xc0a821'); // returns "[2002:00c0:a821::]"
```


<p class="message-notice">since version <code>7.2.0</code></p>

It is possible to determine if a given domain is or can be converted to an IPv4 host the new `Converter::isIpv4`
Expand Down
6 changes: 0 additions & 6 deletions docs/interfaces/7.0/ipv6.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,6 @@ echo Converter::expand('[1050:0000:0000:0000:0005:0000:300c:326b]');
// returns [1050:0000:0000:0000:0005:0000:300c:326b]
```

The compress method can also be used to convert a IPv6-mapped IPv4 address into a IPv4
address in its decimal form. Conversely, presented with an IPv4 address,
the `Converted::expand` method will do the opposite and convert the
IPv4 address into its IPv6-mapped IPv4 address representation in long form.


To complement the host related methods the class also provide stricter IPv6 compress and expand
methods using the `Converter::compressIp` and `Converter::expandId` methods. Those methods will
throw if the submitted value is not a valid IPv6 representation.
Expand Down
5 changes: 4 additions & 1 deletion interfaces/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ All Notable changes to `League\Uri\Interfaces` will be documented in this file
- `UriInterface::toComponents` returns an associative array containing all URI components values.
- `UriInterface::getUsername` returns the encoded user component of the URI.
- `UriInterface::getPassword` returns the encoded scheme-specific information about how to gain authorization to access the resource.
- `Uri\IPv6\Converter` allow expanding and compressing IPv6.
- `Uri\IPv6\Converter` allows expanding and compressing IPv6.
- `Uri\IPv4\Converter::to6to4` allows converting an IPv4 into an IPv6 host using the 6to4 notation.
- `Uri\IPv4\Converter::toIPv4MappedIPv6` allows mapping an IPv4 address into an IPv6 one.

### Fixed

- Adding Host resolution caching to speed up URI parsing in `UriString`
- `UriString::parseAuthority` accepts `Stringable` object as valid input
- `Uri\IPv4\Converter::toDecimal` also handles 6to4 and IPv4 mapped address algorithm.

### Deprecated

Expand Down
82 changes: 81 additions & 1 deletion interfaces/IPv4/Converter.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
use function preg_match;
use function str_ends_with;
use function substr;
use const FILTER_FLAG_IPV4;
use const FILTER_FLAG_IPV6;
use const FILTER_VALIDATE_IP;

final class Converter
{
Expand All @@ -43,6 +46,9 @@ final class Converter
'/^(?<number>\d+)$/' => 10,
];

private const IPV6_6TO4_PREFIX = '2002:';
private const IPV4_MAPPED_PREFIX = '::ffff:';

private readonly mixed $maxIPv4Number;

public function __construct(
Expand Down Expand Up @@ -95,7 +101,58 @@ public static function fromEnvironment(): self

public function isIpv4(Stringable|string|null $host): bool
{
return null !== $this->toDecimal($host);
if (null === $host) {
return false;
}

if (null !== $this->toDecimal($host)) {
return true;
}

$host = (string) $host;
if (false === filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return false;
}

$ipAddress = strtolower((string) inet_ntop((string) inet_pton($host)));
if (str_starts_with($ipAddress, self::IPV4_MAPPED_PREFIX)) {
return false !== filter_var(substr($ipAddress, 7), FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
}

if (!str_starts_with($ipAddress, self::IPV6_6TO4_PREFIX)) {
return false;
}

$hexParts = explode(':', substr($ipAddress, 5, 9));

return count($hexParts) > 1
&& false !== long2ip((int) hexdec($hexParts[0]) * 65536 + (int) hexdec($hexParts[1]));
}

public function to6to4(Stringable|string|null $host): ?string
{
$host = $this->toDecimal($host);
if (null === $host) {
return null;
}

/** @var array<string> $parts */
$parts = array_map(
fn (string $part): string => sprintf('%02x', $part),
explode('.', $host)
);

return '['.self::IPV6_6TO4_PREFIX . $parts[0] . $parts[1] . ':' . $parts[2] . $parts[3] . '::]';
}

public function toIPv4MappedIPv6(Stringable|string|null $host): ?string
{
$host = $this->toDecimal($host);

return match ($host) {
null => null,
default => '['.self::IPV4_MAPPED_PREFIX.$host.']',
};
}

public function toOctal(Stringable|string|null $host): ?string
Expand Down Expand Up @@ -133,6 +190,29 @@ public function toHexadecimal(Stringable|string|null $host): ?string
public function toDecimal(Stringable|string|null $host): ?string
{
$host = (string) $host;
if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
$host = substr($host, 1, -1);
if (false === filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return null;
}

$ipAddress = strtolower((string) inet_ntop((string) inet_pton($host)));
if (str_starts_with($ipAddress, self::IPV4_MAPPED_PREFIX)) {
return substr($ipAddress, 7);
}

if (!str_starts_with($ipAddress, self::IPV6_6TO4_PREFIX)) {
return null;
}

$hexParts = explode(':', substr($ipAddress, 5, 9));

return (string) match (true) {
count($hexParts) < 2 => null,
default => long2ip((int) hexdec($hexParts[0]) * 65536 + (int) hexdec($hexParts[1])),
};
}

if (1 !== preg_match(self::REGEXP_IPV4_HOST, $host)) {
return null;
}
Expand Down
43 changes: 31 additions & 12 deletions interfaces/IPv4/ConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ public function testParseWithAutoDetectCalculator(?string $input, ?string $expec
}

#[DataProvider('providerHost')]
public function testConvertToDecimal(string $input, string $decimal, string $octal, string $hexadecimal): void
public function testConvertToDecimal(
string $input,
string $decimal,
string $octal,
string $hexadecimal,
string $sixToFour,
string $ipv4Mapped,
): void
{
self::assertSame($octal, Converter::fromGMP()->toOctal($input));
self::assertSame($octal, Converter::fromNative()->toOctal($input));
Expand All @@ -40,23 +47,34 @@ public function testConvertToDecimal(string $input, string $decimal, string $oct
self::assertSame($hexadecimal, Converter::fromGMP()->toHexadecimal($input));
self::assertSame($hexadecimal, Converter::fromNative()->toHexadecimal($input));
self::assertSame($hexadecimal, Converter::fromBCMath()->toHexadecimal($input));

self::assertSame($sixToFour, Converter::fromBCMath()->to6to4($input));
self::assertSame($sixToFour, Converter::fromNative()->to6to4($input));
self::assertSame($sixToFour, Converter::fromBCMath()->to6to4($input));

self::assertSame($ipv4Mapped, Converter::fromBCMath()->toIPv4MappedIPv6($input));
self::assertSame($ipv4Mapped, Converter::fromNative()->toIPv4MappedIPv6($input));
self::assertSame($ipv4Mapped, Converter::fromBCMath()->toIPv4MappedIPv6($input));

self::assertTrue(Converter::fromEnvironment()->isIpv4($input));
}

public static function providerHost(): array
{
return [
'0 host' => ['0', '0.0.0.0', '0000.0000.0000.0000', '0x0000'],
'normal IP' => ['192.168.0.1', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801'],
'normal IP ending with a dot' => ['192.168.0.1.', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801'],
'octal (1)' => ['030052000001', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801'],
'octal (2)' => ['0300.0250.0000.0001', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801'],
'hexadecimal (1)' => ['0x', '0.0.0.0', '0000.0000.0000.0000', '0x0000'],
'hexadecimal (2)' => ['0xffffffff', '255.255.255.255', '0377.0377.0377.0377', '0xffffffff'],
'decimal (1)' => ['3232235521', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801'],
'decimal (2)' => ['999999999', '59.154.201.255', '0073.0232.0311.0377', '0x3b9ac9ff'],
'decimal (3)' => ['256', '0.0.1.0', '0000.0000.0001.0000', '0x0010'],
'decimal (4)' => ['192.168.257', '192.168.1.1', '0300.0250.0001.0001', '0xc0a811'],
'0 host' => ['0', '0.0.0.0', '0000.0000.0000.0000', '0x0000', '[2002:0000:0000::]', '[::ffff:0.0.0.0]'],
'normal IP' => ['192.168.0.1', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'],
'normal IP ending with a dot' => ['192.168.0.1.', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'],
'octal (1)' => ['030052000001', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'],
'octal (2)' => ['0300.0250.0000.0001', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'],
'hexadecimal (1)' => ['0x', '0.0.0.0', '0000.0000.0000.0000', '0x0000', '[2002:0000:0000::]', '[::ffff:0.0.0.0]'],
'hexadecimal (2)' => ['0xffffffff', '255.255.255.255', '0377.0377.0377.0377', '0xffffffff', '[2002:ffff:ffff::]', '[::ffff:255.255.255.255]'],
'decimal (1)' => ['3232235521', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'],
'decimal (2)' => ['999999999', '59.154.201.255', '0073.0232.0311.0377', '0x3b9ac9ff', '[2002:3b9a:c9ff::]', '[::ffff:59.154.201.255]'],
'decimal (3)' => ['256', '0.0.1.0', '0000.0000.0001.0000', '0x0010', '[2002:0000:0100::]', '[::ffff:0.0.1.0]'],
'decimal (4)' => ['192.168.257', '192.168.1.1', '0300.0250.0001.0001', '0xc0a811', '[2002:c0a8:0101::]', '[::ffff:192.168.1.1]'],
'IPv4 Mapped to IPv6 notation' => ['[::ffff:192.168.0.1]', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'],
'IPv4 6to4 notation' => ['[2002:c0a8:0001::]', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'],
];
}

Expand Down Expand Up @@ -89,6 +107,7 @@ public static function providerInvalidHost(): array
'invalid host (12)' => ['255.255.256.255'],
'invalid host (13)' => ['0ffaed'],
'invalid host (14)' => ['192.168.1.0x3000000'],
'invalid host (15)' => ['[::1]'],
];
}
}
38 changes: 6 additions & 32 deletions interfaces/IPv6/Converter.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Stringable;
use ValueError;

use const FILTER_FLAG_IPV6;
use const FILTER_VALIDATE_IP;

Expand All @@ -18,9 +19,6 @@

final class Converter
{
private const IPV4_MAPPED_PREFIX = '::ffff:';
private const IPV6_6TO4_PREFIX = '2002:';

/**
* Significant 10 bits of IP to detect Zone ID regular expression pattern.
*
Expand All @@ -30,34 +28,14 @@ final class Converter

public static function compressIp(string $ipAddress): string
{
if (false === filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
throw new ValueError('The submitted IP is not a valid IPv6 address.');
}

$ipAddress = strtolower((string) inet_ntop((string) inet_pton($ipAddress)));
if (str_starts_with($ipAddress, self::IPV4_MAPPED_PREFIX)) {
return substr($ipAddress, 7);
}

if (!str_starts_with($ipAddress, self::IPV6_6TO4_PREFIX)) {
return $ipAddress;
}

$hexPart = substr($ipAddress, 5, 9);
$hexParts = explode(':', $hexPart);

return (string) match (true) {
count($hexParts) < 2 => $ipAddress,
default => long2ip((int) hexdec($hexParts[0]) * 65536 + (int) hexdec($hexParts[1])),
return match (filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
false => throw new ValueError('The submitted IP is not a valid IPv6 address.'),
default => strtolower((string) inet_ntop((string) inet_pton($ipAddress))),
};
}

public static function expandIp(string $ipAddress): string
{
if (false !== filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ipAddress = self::IPV4_MAPPED_PREFIX.$ipAddress;
}

if (false === filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
throw new ValueError('The submitted IP is not a valid IPv6 address.');
}
Expand Down Expand Up @@ -102,8 +80,8 @@ private static function build(array $components): string
$components['ipAddress'] ??= null;
$components['zoneIdentifier'] ??= null;

if (null !== $components['ipAddress'] && false !== filter_var($components['ipAddress'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $components['ipAddress'];
if (null === $components['ipAddress']){
return '';
}

return '['.$components['ipAddress'].match ($components['zoneIdentifier']) {
Expand All @@ -128,10 +106,6 @@ private static function parse(Stringable|string|null $host): array
return ['ipAddress' => null, 'zoneIdentifier' => null];
}

if (false !== filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return ['ipAddress' => self::IPV4_MAPPED_PREFIX.$host, 'zoneIdentifier' => null];
}

if (!str_starts_with($host, '[')) {
return ['ipAddress' => null, 'zoneIdentifier' => null];
}
Expand Down
2 changes: 1 addition & 1 deletion interfaces/IPv6/ConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public static function ipv6NormalizationUriProvider(): iterable
yield 'no change happen with a IPv4 ipv6' => [
'ipv6' => '127.0.0.1',
'ipv6Compressed' => '127.0.0.1',
'ipv6Expanded' => '[0000:0000:0000:0000:0000:ffff:7f00:0001]',
'ipv6Expanded' => '127.0.0.1',
];

yield 'IPv6 gets expanded if needed' => [
Expand Down
12 changes: 10 additions & 2 deletions uri/BaseUri.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
use League\Uri\Contracts\UriAccess;
use League\Uri\Contracts\UriInterface;
use League\Uri\Exceptions\MissingFeature;
use League\Uri\Idna\Converter;
use League\Uri\Idna\Converter as IdnaConverter;
use League\Uri\IPv4\Converter as IPv4Converter;
use League\Uri\IPv6\Converter as IPv6Converter;
use Psr\Http\Message\UriFactoryInterface;
Expand Down Expand Up @@ -267,7 +267,15 @@ public function isSameDocument(Stringable|string $uri): bool
*/
public function hasIdn(): bool
{
return Converter::isIdn($this->uri->getHost());
return IdnaConverter::isIdn($this->uri->getHost());
}

/**
* Tells whether the URI contains an IPv4 regardless if it is mapped or native
*/
public function hasIPv4(): bool
{
return IPv4Converter::fromEnvironment()->isIpv4($this->uri->getHost());
}

/**
Expand Down

0 comments on commit 9e84eb0

Please sign in to comment.