diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 20c0a5d7d9a33..f842523fa7196 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 5.4 --- + * Add a `Cidr` constraint to validate CIDR notations * Add a `CssColor` constraint to validate CSS colors * Add support for `ConstraintViolationList::createFromMessage()` * Add error's uid to `Count` and `Length` constraints with "exactly" option enabled diff --git a/src/Symfony/Component/Validator/Constraints/Cidr.php b/src/Symfony/Component/Validator/Constraints/Cidr.php new file mode 100644 index 0000000000000..387c5996a053d --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/Cidr.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; + +/** + * Validates that a value is a valid CIDR notation. + * + * @Annotation + * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) + * + * @author Sorin Pop + * @author Calin Bolea + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class Cidr extends Constraint +{ + public const INVALID_CIDR_ERROR = '5649e53a-5afb-47c5-a360-ffbab3be8567'; + public const OUT_OF_RANGE_ERROR = 'b9f14a51-acbd-401a-a078-8c6b204ab32f'; + + protected static $errorNames = [ + self::INVALID_CIDR_ERROR => 'INVALID_CIDR_ERROR', + self::OUT_OF_RANGE_ERROR => 'OUT_OF_RANGE_VIOLATION', + ]; + + private const NET_MAXES = [ + Ip::ALL => 128, + Ip::V4 => 32, + Ip::V6 => 128, + ]; + + public $version = Ip::ALL; + + public $message = 'This value is not a valid CIDR notation.'; + + public $netmaskRangeViolationMessage = 'The value of the netmask should be between {{ min }} and {{ max }}.'; + + public $netmaskMin = 0; + + public $netmaskMax; + + public function __construct( + array $options = null, + string $version = null, + int $netmaskMin = null, + int $netmaskMax = null, + string $message = null, + array $groups = null, + $payload = null + ) { + $this->version = $version ?? $options['version'] ?? $this->version; + + if (!\in_array($this->version, array_keys(self::NET_MAXES))) { + throw new ConstraintDefinitionException(sprintf('The option "version" must be one of "%s".', implode('", "', array_keys(self::NET_MAXES)))); + } + + $this->netmaskMin = $netmaskMin ?? $options['netmaskMin'] ?? $this->netmaskMin; + $this->netmaskMax = $netmaskMax ?? $options['netmaskMax'] ?? self::NET_MAXES[$this->version]; + $this->message = $message ?? $this->message; + + unset($options['netmaskMin'], $options['netmaskMax'], $options['version']); + + if ($this->netmaskMin < 0 || $this->netmaskMax > self::NET_MAXES[$this->version] || $this->netmaskMin > $this->netmaskMax) { + throw new ConstraintDefinitionException(sprintf('The netmask range must be between 0 and %d.', self::NET_MAXES[$this->version])); + } + + parent::__construct($options, $groups, $payload); + } +} diff --git a/src/Symfony/Component/Validator/Constraints/CidrValidator.php b/src/Symfony/Component/Validator/Constraints/CidrValidator.php new file mode 100644 index 0000000000000..c90ebcfae35f7 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/CidrValidator.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; + +class CidrValidator extends ConstraintValidator +{ + public function validate($value, Constraint $constraint): void + { + if (!$constraint instanceof Cidr) { + throw new UnexpectedTypeException($constraint, Cidr::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!\is_string($value)) { + throw new UnexpectedValueException($value, 'string'); + } + + $cidrParts = explode('/', $value, 2); + + if (!isset($cidrParts[1]) + || !ctype_digit($cidrParts[1]) + || '' === $cidrParts[0] + ) { + $this->context + ->buildViolation($constraint->message) + ->setCode(Cidr::INVALID_CIDR_ERROR) + ->addViolation(); + + return; + } + + $ipAddress = $cidrParts[0]; + $netmask = (int) $cidrParts[1]; + + $validV4 = Ip::V6 !== $constraint->version + && filter_var($ipAddress, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4) + && $netmask <= 32; + + $validV6 = Ip::V4 !== $constraint->version + && filter_var($ipAddress, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6); + + if (!$validV4 && !$validV6) { + $this->context + ->buildViolation($constraint->message) + ->setCode(Cidr::INVALID_CIDR_ERROR) + ->addViolation(); + + return; + } + + if ($netmask < $constraint->netmaskMin || $netmask > $constraint->netmaskMax) { + $this->context + ->buildViolation($constraint->netmaskRangeViolationMessage) + ->setParameter('{{ min }}', $constraint->netmaskMin) + ->setParameter('{{ max }}', $constraint->netmaskMax) + ->setCode(Cidr::OUT_OF_RANGE_ERROR) + ->addViolation(); + } + } +} diff --git a/src/Symfony/Component/Validator/Resources/translations/validators.en.xlf b/src/Symfony/Component/Validator/Resources/translations/validators.en.xlf index 3ba8d874da3ec..34c54212d842f 100644 --- a/src/Symfony/Component/Validator/Resources/translations/validators.en.xlf +++ b/src/Symfony/Component/Validator/Resources/translations/validators.en.xlf @@ -394,6 +394,14 @@ This value is not a valid CSS color. This value is not a valid CSS color. + + This value is not a valid CIDR notation. + This value is not a valid CIDR notation. + + + The value of the netmask should be between {{ min }} and {{ max }}. + The value of the netmask should be between {{ min }} and {{ max }}. + diff --git a/src/Symfony/Component/Validator/Resources/translations/validators.fr.xlf b/src/Symfony/Component/Validator/Resources/translations/validators.fr.xlf index 39126b312b2e3..bc03a0a3dc99e 100644 --- a/src/Symfony/Component/Validator/Resources/translations/validators.fr.xlf +++ b/src/Symfony/Component/Validator/Resources/translations/validators.fr.xlf @@ -394,6 +394,14 @@ This value is not a valid CSS color. Cette valeur n'est pas une couleur CSS valide. + + This value is not a valid CIDR notation. + Cette valeur n'est pas une notation CIDR valide. + + + The value of the netmask should be between {{ min }} and {{ max }}. + La valeur du masque de réseau doit être comprise entre {{ min }} et {{ max }}. + diff --git a/src/Symfony/Component/Validator/Resources/translations/validators.ro.xlf b/src/Symfony/Component/Validator/Resources/translations/validators.ro.xlf index 64a5c80fb6d24..7fba2cd1e0e73 100644 --- a/src/Symfony/Component/Validator/Resources/translations/validators.ro.xlf +++ b/src/Symfony/Component/Validator/Resources/translations/validators.ro.xlf @@ -390,6 +390,14 @@ This value should be a valid expression. Această valoare ar trebui să fie o expresie validă. + + This value is not a valid CIDR notation. + Această valoare nu este o notație CIDR validă. + + + The value of the netmask should be between {{ min }} and {{ max }}. + Valoarea netmask-ului trebuie sa fie intre {{ min }} si {{ max }}. + diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CidrTest.php b/src/Symfony/Component/Validator/Tests/Constraints/CidrTest.php new file mode 100644 index 0000000000000..1e1dd16902327 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/CidrTest.php @@ -0,0 +1,151 @@ +version); + self::assertEquals(0, $cidrConstraint->netmaskMin); + self::assertEquals(128, $cidrConstraint->netmaskMax); + } + + public function testForV4() + { + $cidrConstraint = new Cidr(['version' => Ip::V4]); + + self::assertEquals(Ip::V4, $cidrConstraint->version); + self::assertEquals(0, $cidrConstraint->netmaskMin); + self::assertEquals(32, $cidrConstraint->netmaskMax); + } + + public function testForV6() + { + $cidrConstraint = new Cidr(['version' => Ip::V6]); + + self::assertEquals(Ip::V6, $cidrConstraint->version); + self::assertEquals(0, $cidrConstraint->netmaskMin); + self::assertEquals(128, $cidrConstraint->netmaskMax); + } + + public function testWithInvalidVersion() + { + $availableVersions = [Ip::ALL, Ip::V4, Ip::V6]; + + self::expectException(ConstraintDefinitionException::class); + self::expectExceptionMessage(sprintf('The option "version" must be one of "%s".', implode('", "', $availableVersions))); + + new Cidr(['version' => '8']); + } + + /** + * @dataProvider getValidMinMaxValues + */ + public function testWithValidMinMaxValues(string $ipVersion, int $netmaskMin, int $netmaskMax) + { + $cidrConstraint = new Cidr([ + 'version' => $ipVersion, + 'netmaskMin' => $netmaskMin, + 'netmaskMax' => $netmaskMax, + ]); + + self::assertEquals($ipVersion, $cidrConstraint->version); + self::assertEquals($netmaskMin, $cidrConstraint->netmaskMin); + self::assertEquals($netmaskMax, $cidrConstraint->netmaskMax); + } + + /** + * @dataProvider getInvalidMinMaxValues + */ + public function testWithInvalidMinMaxValues(string $ipVersion, int $netmaskMin, int $netmaskMax) + { + $expectedMax = Ip::V4 == $ipVersion ? 32 : 128; + + self::expectException(ConstraintDefinitionException::class); + self::expectExceptionMessage(sprintf('The netmask range must be between 0 and %d.', $expectedMax)); + + new Cidr([ + 'version' => $ipVersion, + 'netmaskMin' => $netmaskMin, + 'netmaskMax' => $netmaskMax, + ]); + } + + public function getInvalidMinMaxValues(): array + { + return [ + [Ip::ALL, -1, 23], + [Ip::ALL, 23, 130], + [Ip::ALL, 2, -4], + [Ip::ALL, -12, -40], + [Ip::V4, 0, 33], + [Ip::V4, 2, -10], + [Ip::V4, -4, 128], + [Ip::V4, -5, -1], + [Ip::V6, 5, 200], + [Ip::V6, -1, 120], + [Ip::V6, 0, -10], + [Ip::V6, -15, -20], + ]; + } + + public function getValidMinMaxValues(): array + { + return [ + [Ip::ALL, 0, 23], + [Ip::ALL, 23, 120], + [Ip::V4, 0, 5], + [Ip::V4, 2, 10], + [Ip::V6, 0, 43], + [Ip::V6, 33, 100], + ]; + } + + /** + * @requires PHP 8 + */ + public function testAttributes() + { + $metadata = new ClassMetadata(CidrDummy::class); + $loader = new AnnotationLoader(); + self::assertTrue($loader->loadClassMetadata($metadata)); + + [$aConstraint] = $metadata->properties['a']->getConstraints(); + self::assertSame(Ip::ALL, $aConstraint->version); + self::assertSame(0, $aConstraint->netmaskMin); + self::assertSame(128, $aConstraint->netmaskMax); + + [$bConstraint] = $metadata->properties['b']->getConstraints(); + self::assertSame(Ip::V6, $bConstraint->version); + self::assertSame('myMessage', $bConstraint->message); + self::assertSame(10, $bConstraint->netmaskMin); + self::assertSame(126, $bConstraint->netmaskMax); + self::assertSame(['Default', 'CidrDummy'], $bConstraint->groups); + + [$cConstraint] = $metadata->properties['c']->getConstraints(); + self::assertSame(['my_group'], $cConstraint->groups); + self::assertSame('some attached data', $cConstraint->payload); + } +} + +class CidrDummy +{ + #[Cidr] + private $a; + + #[Cidr(version: Ip::V6, message: 'myMessage', netmaskMin: 10, netmaskMax: 126)] + private $b; + + #[Cidr(groups: ['my_group'], payload: 'some attached data')] + private $c; +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CidrValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/CidrValidatorTest.php new file mode 100644 index 0000000000000..2816c31c957e9 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/CidrValidatorTest.php @@ -0,0 +1,247 @@ +validator->validate(null, new Cidr()); + + $this->assertNoViolation(); + } + + public function testEmptyStringIsValid() + { + $this->validator->validate('', new Cidr()); + + $this->assertNoViolation(); + } + + public function testInvalidConstraint() + { + $this->expectException(UnexpectedTypeException::class); + + $this->validator->validate('neko', new NotNull()); + } + + public function testExpectsStringCompatibleType() + { + $this->expectException(UnexpectedValueException::class); + + $this->validator->validate(123456, new Cidr()); + } + + /** + * @dataProvider getWithInvalidNetmask + */ + public function testInvalidNetmask(string $cidr) + { + $this->validator->validate($cidr, new Cidr()); + + $this + ->buildViolation('This value is not a valid CIDR notation.') + ->setCode(Cidr::INVALID_CIDR_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getWithInvalidIps + */ + public function testInvalidIpValue(string $cidr) + { + $this->validator->validate($cidr, new Cidr()); + + $this + ->buildViolation('This value is not a valid CIDR notation.') + ->setCode(Cidr::INVALID_CIDR_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getValid + */ + public function testValidCidr(string $cidr, string $version) + { + $this->validator->validate($cidr, new Cidr(['version' => $version])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getWithInvalidMasksAndIps + */ + public function testInvalidIpAddressAndNetmask(string $cidr) + { + $this->validator->validate($cidr, new Cidr()); + $this + ->buildViolation('This value is not a valid CIDR notation.') + ->setCode(Cidr::INVALID_CIDR_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getOutOfRangeNetmask + */ + public function testOutOfRangeNetmask(string $cidr, string $version = null, int $min = null, int $max = null) + { + $cidrConstraint = new Cidr([ + 'version' => $version, + 'netmaskMin' => $min, + 'netmaskMax' => $max, + ]); + $this->validator->validate($cidr, $cidrConstraint); + + $this + ->buildViolation('The value of the netmask should be between {{ min }} and {{ max }}.') + ->setParameter('{{ min }}', $cidrConstraint->netmaskMin) + ->setParameter('{{ max }}', $cidrConstraint->netmaskMax) + ->setCode(Cidr::OUT_OF_RANGE_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getWithWrongVersion + */ + public function testWrongVersion(string $cidr, string $version) + { + $this->validator->validate($cidr, new Cidr(['version' => $version])); + + $this + ->buildViolation('This value is not a valid CIDR notation.') + ->setCode(Cidr::INVALID_CIDR_ERROR) + ->assertRaised(); + } + + public function getWithInvalidIps(): array + { + return [ + ['0/20'], + ['0.0/20'], + ['0.0.0/20'], + ['256.0.0.0/20'], + ['0.256.0.0/21'], + ['0.0.256.0/22'], + ['0.0.0.256/30'], + ['-1.0.0.0/15'], + ['foobar/10'], + ['z001:0db8:85a3:0000:0000:8a2e:0370:7334/20'], + ['fe80/100'], + ['fe80:8329/15'], + ['fe80:::202:b3ff:fe1e:8329/128'], + ['fe80::202:b3ff::fe1e:8329/48'], + ['2001:0db8:85a3:0000:0000:8a2e:0370:0.0.0.0/32'], + ['::0.0/32'], + ['::0.0.0/32'], + ['::256.0.0.0/32'], + ['::0.256.0.0/32'], + ['::0.0.256.0/32'], + ['::0.0.0.256/32'], + ['/32'], + ['/128'], + ]; + } + + public function getValid(): array + { + return [ + ['127.0.0.0/32', Ip::ALL], + ['0.0.0.0/32', Ip::V4], + ['10.0.0.0/24', Ip::V4], + ['123.45.67.178/20', Ip::V4], + ['172.16.0.0/12', Ip::V4], + ['192.168.1.0/25', Ip::V4], + ['224.0.0.1/10', Ip::V4], + ['255.255.255.255/20', Ip::V4], + ['127.0.0.0/32', Ip::V4], + ['2001:0db8:85a3:0000:0000:8a2e:0370:7334/128', Ip::V6], + ['2001:0DB8:85A3:0000:0000:8A2E:0370:7334/128', Ip::V6], + ['2001:0Db8:85a3:0000:0000:8A2e:0370:7334/32', Ip::V6], + ['fdfe:dcba:9876:ffff:fdc6:c46b:bb8f:7d4c/28', Ip::V6], + ['fdc6:c46b:bb8f:7d4c:fdc6:c46b:bb8f:7d4c/55', Ip::V6], + ['fdc6:c46b:bb8f:7d4c:0000:8a2e:0370:7334/60', Ip::V6], + ['fe80:0000:0000:0000:0202:b3ff:fe1e:8329/20', Ip::V6], + ['fe80:0:0:0:202:b3ff:fe1e:8329/4', Ip::V6], + ['fe80::202:b3ff:fe1e:8329/0', Ip::V6], + ['0:0:0:0:0:0:0:0/1', Ip::V6], + ['::/20', Ip::V6], + ['0::/120', Ip::V6], + ['::0/128', Ip::V6], + ['0::0/56', Ip::V6], + ['2001:0db8:85a3:0000:0000:8a2e:0.0.0.0/128', Ip::V6], + ['::0.0.0.0/128', Ip::V6], + ['::255.255.255.255/32', Ip::V6], + ['::123.45.67.178/120', Ip::V6], + ['::123.45.67.178/120', Ip::ALL], + ]; + } + + public function getWithInvalidNetmask(): array + { + return [ + ['192.168.1.0/-1'], + ['0.0.0.0/foobar'], + ['10.0.0.0/128'], + ['123.45.67.178/aaa'], + ['172.16.0.0//'], + ['255.255.255.255/1/4'], + ['224.0.0.1'], + ['127.0.0.0/28c'], + ['2001:0Db8:85a3:0000:0000:8A2e:0370:7334/28a'], + ['fdfe:dcba:9876:ffff:fdc6:c46b:bb8f:7d4c/neko'], + ['fdc6:c46b:bb8f:7d4c:fdc6:c46b:bb8f:7d4c/-8amba'], + ['fdc6:c46b:bb8f:7d4c:0000:8a2e:0370:7334/-1aa'], + ['fe80:0000:0000:0000:0202:b3ff:fe1e:8329/11*'], + ]; + } + + public function getWithInvalidMasksAndIps(): array + { + return [ + ['0.0.0.0/foobar'], + ['10.0.0.0/128'], + ['123.45.67.178/aaa'], + ['172.16.0.0//'], + ['172.16.0.0/a/'], + ['172.16.0.0/1/'], + ['fe80/neko'], + ['fe80:8329/-8'], + ['fe80:::202:b3ff:fe1e:8329//'], + ['fe80::202:b3ff::fe1e:8329/1/'], + ['::0.0.0/a/'], + ['::256.0.0.0/-1aa'], + ['::0.256.0.0/1b'], + ]; + } + + public function getOutOfRangeNetmask(): array + { + return [ + ['10.0.0.0/24', Ip::V4, 10, 20], + ['2001:0DB8:85A3:0000:0000:8A2E:0370:7334/24', Ip::V6, 10, 20], + ]; + } + + public function getWithWrongVersion(): array + { + return [ + ['2001:0db8:85a3:0000:0000:8a2e:0370:7334/12', Ip::V4], + ['0.0.0.0/31', Ip::V6], + ['10.0.0.0/24', Ip::V6], + ['2001:0db8:85a3:0000:0000:8a2e:0370:7334/13', Ip::V4], + ]; + } +}