Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new Validator: HostWithPublicIPv4Address #257

Merged
merged 2 commits into from
Jun 14, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/book/v2/set.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The following validators come with the laminas-validator distribution.
- [GreaterThan](validators/greater-than.md)
- [Hex](validators/hex.md)
- [Hostname](validators/hostname.md)
- [HostWithPublicIPv4Address](validators/host-with-public-ipv4-address.md)
- [Iban](validators/iban.md)
- [Identical](validators/identical.md)
- [InArray](validators/in-array.md)
Expand Down
39 changes: 39 additions & 0 deletions docs/book/v2/validators/host-with-public-ipv4-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Host with Public IPv4 Address Validator

`Laminas\Validator\HostWithPublicIPv4Address` allows you to validate that an IP address is not a reserved address such as 127.0.0.1, or that a hostname does not point to a known, reserved address.

## Supported options

This validator has no options

## Basic usage

```php
$validator = new Laminas\Validator\HostWithPublicIPv4Address();

if ($validator->isValid('example.com')) {
// hostname appears to be valid
} else {
// hostname is invalid; print the reasons
foreach ($validator->getMessages() as $message) {
echo "$message\n";
}
}
```

```php
$validator = new Laminas\Validator\HostWithPublicIPv4Address();

if ($validator->isValid('192.168.0.1')) {
// hostname appears to be valid
} else {
// hostname is invalid; print the reasons
foreach ($validator->getMessages() as $message) {
echo "$message\n";
}
}
```

## Hostnames with multiple records

When validating a hostname as opposed to an IP address, if that hostname resolves to multiple IPv4 addresses and _any_ of those addresses are private or reserved, then the validator will deem the hostname invalid.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ nav:
- GreaterThan: v2/validators/greater-than.md
- Hex: v2/validators/hex.md
- Hostname: v2/validators/hostname.md
- HostWithPublicIPv4Address: v2/validators/host-with-public-ipv4-address.md
- Iban: v2/validators/iban.md
- Identical: v2/validators/identical.md
- InArray: v2/validators/in-array.md
Expand Down
7 changes: 7 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
<code><![CDATA[$this->options]]></code>
<code><![CDATA[$this->options]]></code>
<code><![CDATA[$this->options]]></code>
<code><![CDATA[$this->options]]></code>
</UndefinedThisPropertyAssignment>
</file>
<file src="src/Barcode.php">
Expand Down Expand Up @@ -2537,6 +2538,12 @@
<code><![CDATA[basicDataProvider]]></code>
</PossiblyUnusedMethod>
</file>
<file src="test/HostWithPublicIPv4AddressTest.php">
<PossiblyUnusedMethod>
<code><![CDATA[releasedReservedIpProvider]]></code>
<code><![CDATA[reservedIpProvider]]></code>
</PossiblyUnusedMethod>
</file>
<file src="test/HostnameTest.php">
<InvalidArgument>
<code><![CDATA[$option]]></code>
Expand Down
98 changes: 98 additions & 0 deletions src/HostWithPublicIPv4Address.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace Laminas\Validator;

use function get_debug_type;
use function gethostbynamel;
use function is_array;
use function is_string;
use function preg_match;

final class HostWithPublicIPv4Address extends AbstractValidator
{
public const ERROR_NOT_STRING = 'hostnameNotString';
public const ERROR_HOSTNAME_NOT_RESOLVED = 'hostnameNotResolved';
public const ERROR_PRIVATE_IP_FOUND = 'privateIpAddressFound';

/** @var array<non-empty-string, non-empty-string> */
protected array $messageTemplates = [
self::ERROR_NOT_STRING => 'Expected a string hostname but received %type%',
self::ERROR_HOSTNAME_NOT_RESOLVED => 'The hostname "%value%" cannot be resolved',
self::ERROR_PRIVATE_IP_FOUND => 'The hostname "%value%" resolves to at least one reserved IPv4 address',
];

protected string $type = 'null';

/** @var array<non-empty-string, non-empty-string> */
protected array $messageVariables = [
'type' => 'type',
'value' => 'value',
];

public function isValid(mixed $value): bool
{
$this->type = get_debug_type($value);

if (! is_string($value)) {
$this->error(self::ERROR_NOT_STRING);

return false;
}

$this->value = $value;

if (! preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $value)) {
gsteel marked this conversation as resolved.
Show resolved Hide resolved
$addressList = gethostbynamel($value);
} else {
$addressList = [$value];
}

if (! is_array($addressList)) {
$this->error(self::ERROR_HOSTNAME_NOT_RESOLVED);

return false;
}

$privateAddressWasFound = false;

// phpcs:disable Generic.Files.LineLength
foreach ($addressList as $server) {
gsteel marked this conversation as resolved.
Show resolved Hide resolved
// Search for 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8
gsteel marked this conversation as resolved.
Show resolved Hide resolved
if (
preg_match('/^(0|10|127)(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){3}$/', $server)
||
// Search for 100.64.0.0/10
preg_match('/^100\.(6[0-4]|[7-9][0-9]|1[0-1][0-9]|12[0-7])(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server)
||
// Search for 172.16.0.0/12
preg_match('/^172\.(1[6-9]|2[0-9]|3[0-1])(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server)
||
// Search for 198.18.0.0/15
preg_match('/^198\.(1[8-9])(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server)
||
// Search for 169.254.0.0/16, 192.168.0.0/16
preg_match('/^(169\.254|192\.168)(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server)
||
// Search for 192.0.2.0/24, 192.88.99.0/24, 198.51.100.0/24, 203.0.113.0/24
preg_match('/^(192\.0\.2|192\.88\.99|198\.51\.100|203\.0\.113)\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))$/', $server)
||
// Search for 224.0.0.0/4, 240.0.0.0/4
preg_match('/^(2(2[4-9]|[3-4][0-9]|5[0-5]))(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){3}$/', $server)
) {
$privateAddressWasFound = true;

break;
}
}

if ($privateAddressWasFound) {
$this->error(self::ERROR_PRIVATE_IP_FOUND);

return false;
}

return true;
}
}
1 change: 1 addition & 0 deletions src/ValidatorPluginManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ class ValidatorPluginManager extends AbstractPluginManager
GreaterThan::class => InvokableFactory::class,
Hex::class => InvokableFactory::class,
Hostname::class => InvokableFactory::class,
HostWithPublicIPv4Address::class => InvokableFactory::class,
Iban::class => InvokableFactory::class,
Identical::class => InvokableFactory::class,
InArray::class => InvokableFactory::class,
Expand Down
157 changes: 157 additions & 0 deletions test/HostWithPublicIPv4AddressTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<?php

declare(strict_types=1);

namespace LaminasTest\Validator;

use Laminas\Validator\HostWithPublicIPv4Address;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class HostWithPublicIPv4AddressTest extends TestCase
{
public function testNonStringInput(): void
{
$validator = new HostWithPublicIPv4Address();
self::assertFalse($validator->isValid(123));
$messages = $validator->getMessages();
self::assertArrayHasKey(HostWithPublicIPv4Address::ERROR_NOT_STRING, $messages);
self::assertSame(
'Expected a string hostname but received int',
$messages[HostWithPublicIPv4Address::ERROR_NOT_STRING],
);
}

public function testUnresolvableHostname(): void
{
$validator = new HostWithPublicIPv4Address();
self::assertFalse($validator->isValid('foo'));
$messages = $validator->getMessages();
self::assertArrayHasKey(HostWithPublicIPv4Address::ERROR_HOSTNAME_NOT_RESOLVED, $messages);
self::assertSame(
'The hostname "foo" cannot be resolved',
$messages[HostWithPublicIPv4Address::ERROR_HOSTNAME_NOT_RESOLVED],
);
}

public function testHostnameThatResolvesToAPrivateIp(): void
{
$validator = new HostWithPublicIPv4Address();
self::assertFalse($validator->isValid('localhost'));
$messages = $validator->getMessages();
self::assertArrayHasKey(HostWithPublicIPv4Address::ERROR_PRIVATE_IP_FOUND, $messages);
self::assertSame(
'The hostname "localhost" resolves to at least one reserved IPv4 address',
$messages[HostWithPublicIPv4Address::ERROR_PRIVATE_IP_FOUND],
);
}

/** @return list<array{0: string}> */
public static function reservedIpProvider(): array
{
return [
// 0.0.0.0/8
['0.0.0.0'],
['0.255.255.255'],

// 10.0.0.0/8
['10.0.0.0'],
['10.255.255.255'],

// 127.0.0.0/8
['127.0.0.0'],
['127.255.255.255'],

// 100.64.0.0/10
['100.64.0.0'],
['100.127.255.255'],

// 172.16.0.0/12
['172.16.0.0'],
['172.31.255.255'],

// 198.18.0.0./15
['198.18.0.0'],
['198.19.255.255'],

// 169.254.0.0/16
['169.254.0.0'],
['169.254.255.255'],

// 192.168.0.0/16
['192.168.0.0'],
['192.168.255.25'],

// 192.0.2.0/24
['192.0.2.0'],
['192.0.2.255'],

// 192.88.99.0/24
['192.88.99.0'],
['192.88.99.255'],

// 198.51.100.0/24
['198.51.100.0'],
['198.51.100.255'],

// 203.0.113.0/24
['203.0.113.0'],
['203.0.113.255'],

// 224.0.0.0/4
['224.0.0.0'],
['239.255.255.255'],

// 240.0.0.0/4
['240.0.0.0'],
['255.255.255.254'],

// 255.255.255.255/32
['255.255.55.255'],
];
}

#[DataProvider('reservedIpProvider')]
public function testAReservedIpIsInvalid(string $reservedIp): void
{
$validator = new HostWithPublicIPv4Address();
self::assertFalse($validator->isValid($reservedIp));
$messages = $validator->getMessages();
self::assertArrayHasKey(HostWithPublicIPv4Address::ERROR_PRIVATE_IP_FOUND, $messages);
self::assertSame(
'The hostname "' . $reservedIp . '" resolves to at least one reserved IPv4 address',
$messages[HostWithPublicIPv4Address::ERROR_PRIVATE_IP_FOUND],
);
}

/** @return list<array{0: string}> */
public static function releasedReservedIpProvider(): array
{
return [
// 128.0.0.0/16
['128.0.0.0'],
['128.0.255.255'],

// 191.255.0.0/16
['191.255.0.0'],
['191.255.255.255'],

// 223.255.255.0/24
['223.255.255.0'],
['223.255.255.255'],
];
}

#[DataProvider('releasedReservedIpProvider')]
public function testPreviouslyReservedIpIsValid(string $ip): void
{
$validator = new HostWithPublicIPv4Address();
self::assertTrue($validator->isValid($ip));
}

public function testAHostnameThatResolvesToPublicIPv4AddressIsValid(): void
{
$validator = new HostWithPublicIPv4Address();
self::assertTrue($validator->isValid('example.com'));
}
}