Validate IP address patterns when creating ip-address redirect conditions

This commit is contained in:
Alejandro Celaya 2024-07-18 21:23:48 +02:00
parent ce2ed237c7
commit 7e2f755dfd
5 changed files with 185 additions and 20 deletions

View file

@ -12,6 +12,7 @@ use Shlinkio\Shlink\Common\Validation\InputFactory;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\enumValues;
@ -71,13 +72,14 @@ class RedirectRulesInputFilter extends InputFilter
$redirectConditionInputFilter->add($type);
$value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true);
$value->getValidatorChain()->attach(new Callback(function (string $value, array $context) {
if ($context[self::CONDITION_TYPE] === RedirectConditionType::DEVICE->value) {
return contains($value, enumValues(DeviceType::class));
}
return true;
}));
$value->getValidatorChain()->attach(new Callback(
fn (string $value, array $context) => match ($context[self::CONDITION_TYPE]) {
RedirectConditionType::DEVICE->value => contains($value, enumValues(DeviceType::class)),
RedirectConditionType::IP_ADDRESS->value => IpAddressUtils::isStaticIpCidrOrWildcard($value),
// RedirectConditionType::LANGUAGE->value => TODO,
default => true,
},
));
$redirectConditionInputFilter->add($value);
$redirectConditionInputFilter->add(

View file

@ -18,6 +18,11 @@ use function str_contains;
final class IpAddressUtils
{
public static function isStaticIpCidrOrWildcard(string $candidate): bool
{
return self::candidateToRange($candidate, ['0', '0', '0', '0']) !== null;
}
/**
* Checks if an IP address matches any of provided groups.
* Every group can be a static IP address (100.200.80.40), a CIDR block (192.168.10.0/24) or a wildcard pattern
@ -40,15 +45,29 @@ final class IpAddressUtils
$ipAddressParts = explode('.', $ipAddress);
return some($groups, function (string $value) use ($ip, $ipAddressParts): bool {
$range = str_contains($value, '*')
? self::parseValueWithWildcards($value, $ipAddressParts)
: Factory::parseRangeString($value);
return $range !== null && $ip->matches($range);
return some($groups, function (string $group) use ($ip, $ipAddressParts): bool {
$range = self::candidateToRange($group, $ipAddressParts);
return $range !== null && $range->contains($ip);
});
}
/**
* Convert a static IP, CIDR block or wildcard pattern into a Range object
*
* @param string[] $ipAddressParts
*/
private static function candidateToRange(string $candidate, array $ipAddressParts): ?RangeInterface
{
return str_contains($candidate, '*')
? self::parseValueWithWildcards($candidate, $ipAddressParts)
: Factory::parseRangeString($candidate);
}
/**
* Try to generate an IP range from a wildcard pattern.
* Factory::parseRangeString can usually do this automatically, but only if wildcards are at the end. This also
* covers cases where wildcards are in between.
*/
private static function parseValueWithWildcards(string $value, array $ipAddressParts): ?RangeInterface
{
$octets = explode('.', $value);

View file

@ -51,9 +51,76 @@ class RedirectRulesDataTest extends TestCase
],
],
]]])]
#[TestWith([['redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'ip-address',
'matchKey' => null,
'matchValue' => 'not an IP address',
],
],
],
]]])]
public function throwsWhenProvidedDataIsInvalid(array $invalidData): void
{
$this->expectException(ValidationException::class);
RedirectRulesData::fromRawData($invalidData);
}
#[Test]
#[TestWith([['redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'ip-address',
'matchKey' => null,
'matchValue' => '1.2.3.4',
],
],
],
]]], 'static IP')]
#[TestWith([['redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'ip-address',
'matchKey' => null,
'matchValue' => '1.2.3.0/24',
],
],
],
]]], 'CIDR block')]
#[TestWith([['redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'ip-address',
'matchKey' => null,
'matchValue' => '1.2.3.*',
],
],
],
]]], 'IP wildcard pattern')]
#[TestWith([['redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'ip-address',
'matchKey' => null,
'matchValue' => '1.2.*.4',
],
],
],
]]], 'in-between IP wildcard pattern')]
public function allowsValidDataToBeSet(array $validData): void
{
$result = RedirectRulesData::fromRawData($validData);
self::assertEquals($result->rules, $validData['redirectRules']);
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Util;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
class IpAddressUtilsTest extends TestCase
{
#[Test]
#[TestWith(['', false], 'empty')]
#[TestWith(['invalid', false], 'invalid')]
#[TestWith(['1.2.3.4', true], 'static IP')]
#[TestWith(['456.2.385.4', false], 'invalid IP')]
#[TestWith(['192.168.1.0/24', true], 'CIDR block')]
#[TestWith(['1.2.*.*', true], 'wildcard pattern')]
#[TestWith(['1.2.*.1', true], 'in-between wildcard pattern')]
public function isStaticIpCidrOrWildcardReturnsExpectedResult(string $candidate, bool $expected): void
{
self::assertEquals($expected, IpAddressUtils::isStaticIpCidrOrWildcard($candidate));
}
}

View file

@ -25,7 +25,7 @@ class SetRedirectRulesTest extends ApiTestCase
];
#[Test]
public function errorIsReturnedWhenInvalidUrlProvided(): void
public function errorIsReturnedWhenInvalidUrlIsProvided(): void
{
$response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/invalid/redirect-rules');
$payload = $this->getJsonResponsePayload($response);
@ -39,16 +39,67 @@ class SetRedirectRulesTest extends ApiTestCase
}
#[Test]
public function errorIsReturnedWhenInvalidDataProvided(): void
{
$response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/abc123/redirect-rules', [
RequestOptions::JSON => [
'redirectRules' => [
#[TestWith([[
'redirectRules' => [
[
'longUrl' => 'invalid',
],
],
]], 'invalid long URL')]
#[TestWith([[
'redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => 'foo',
],
],
]], 'non-array conditions')]
#[TestWith([[
'redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'longUrl' => 'invalid',
'type' => 'invalid',
'matchKey' => null,
'matchValue' => 'foo',
],
],
],
],
]], 'invalid condition type')]
#[TestWith([[
'redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'device',
'matchValue' => 'invalid-device',
'matchKey' => null,
],
],
],
],
]], 'invalid device type')]
#[TestWith([[
'redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'ip-address',
'matchKey' => null,
'matchValue' => 'not an IP address',
],
],
],
],
]], 'invalid IP address')]
public function errorIsReturnedWhenInvalidDataIsProvided(array $bodyPayload): void
{
$response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/abc123/redirect-rules', [
RequestOptions::JSON => $bodyPayload,
]);
$payload = $this->getJsonResponsePayload($response);