Skip to content
Permalink
Browse files

feature #31518 [Validator] Added HostnameValidator (karser)

This PR was merged into the 5.1-dev branch.

Discussion
----------

[Validator] Added HostnameValidator

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes <!-- please update src/**/CHANGELOG.md files -->
| BC breaks?    | no     <!-- see https://symfony.com/bc -->
| Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files -->
| Tests pass?   | yes    <!-- please add some, will be required by reviewers -->
| Fixed tickets | #10088   <!-- #-prefixed issue number(s), if any -->
| License       | MIT
| Doc PR        | symfony/symfony-docs#... <!-- required for new features -->

This PR adds HostnameValidator support. I encountered this need in my project and was surprised that this issue has been open for years.

Here is short example:
```
App\Entity\Acme:
    properties:
        domain:
            - Hostname: ~
        non_tld_domain:
            - Hostname: { requireTld: false }
```
The option `requireTld` is `true` by default and disallows domains like localhost and etc.

Commits
-------

8a08c20 Added HostnameValidator
  • Loading branch information
fabpot committed Jan 10, 2020
2 parents d099bc3 + 8a08c20 commit 7dae1cad64218be8f941441f84c7f5d2b83fde2b
@@ -380,7 +380,7 @@ To get the diff between two versions, go to https://github.com/symfony/symfony/c
* feature #32446 [Lock] rename and deprecate Factory into LockFactory (Simperfit)
* feature #31975 Dynamic bundle assets (garak)
* feature #32429 [VarDumper] Let browsers trigger their own search on double CMD/CTRL + F (ogizanagi)
* feature #32198 [Lock] Split "StoreInterface" into multiple interfaces with less responsability (Simperfit)
* feature #32198 [Lock] Split "StoreInterface" into multiple interfaces with less responsibility (Simperfit)
* feature #31511 [Validator] Allow to use property paths to get limits in range constraint (Lctrs)
* feature #32424 [Console] don't redraw progress bar more than every 100ms by default (nicolas-grekas)
* feature #27905 [MonologBridge] Monolog 2 compatibility (derrabus)
@@ -1,6 +1,10 @@
CHANGELOG
=========

5.1.0
-----
* added the `Hostname` constraint and validator

5.0.0
-----

@@ -0,0 +1,32 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;

/**
* @Annotation
* @Target({"PROPERTY", "METHOD", "ANNOTATION"})
*
* @author Dmitrii Poddubnyi <dpoddubny@gmail.com>
*/
class Hostname extends Constraint
{
const INVALID_HOSTNAME_ERROR = '7057ffdb-0af4-4f7e-bd5e-e9acfa6d7a2d';

protected static $errorNames = [
self::INVALID_HOSTNAME_ERROR => 'INVALID_HOSTNAME_ERROR',
];

public $message = 'This value is not a valid hostname.';
public $requireTld = true;
}
@@ -0,0 +1,69 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;

/**
* @author Dmitrii Poddubnyi <dpoddubny@gmail.com>
*/
class HostnameValidator extends ConstraintValidator
{
/**
* https://tools.ietf.org/html/rfc2606.
*/
private const RESERVED_TLDS = [
'example',
'invalid',
'localhost',
'test',
];

public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof Hostname) {
throw new UnexpectedTypeException($constraint, Hostname::class);
}

if (null === $value || '' === $value) {
return;
}

if (!is_scalar($value) && !(\is_object($value) && method_exists($value, '__toString'))) {
throw new UnexpectedValueException($value, 'string');
}

$value = (string) $value;
if ('' === $value) {
return;
}
if (!$this->isValid($value) || ($constraint->requireTld && !$this->hasValidTld($value))) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $this->formatValue($value))
->setCode(Hostname::INVALID_HOSTNAME_ERROR)
->addViolation();
}
}

private function isValid(string $domain): bool
{
return false !== filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME);
}

private function hasValidTld(string $domain): bool
{
return false !== strpos($domain, '.') && !\in_array(substr($domain, strrpos($domain, '.') + 1), self::RESERVED_TLDS, true);
}
}
@@ -366,6 +366,10 @@
<source>This value should be between {{ min }} and {{ max }}.</source>
<target>Dieser Wert sollte zwischen {{ min }} und {{ max }} sein.</target>
</trans-unit>
<trans-unit id="95">
<source>This value is not a valid hostname.</source>
<target>Dieser Wert ist kein gültiger Hostname.</target>
</trans-unit>
</body>
</file>
</xliff>
@@ -366,6 +366,10 @@
<source>This value should be between {{ min }} and {{ max }}.</source>
<target>This value should be between {{ min }} and {{ max }}.</target>
</trans-unit>
<trans-unit id="95">
<source>This value is not a valid hostname.</source>
<target>This value is not a valid hostname.</target>
</trans-unit>
</body>
</file>
</xliff>
@@ -366,6 +366,10 @@
<source>This value should be between {{ min }} and {{ max }}.</source>
<target>Cette valeur doit être comprise entre {{ min }} et {{ max }}.</target>
</trans-unit>
<trans-unit id="95">
<source>This value is not a valid hostname.</source>
<target>Cette valeur n'est pas un nom d'hôte valide.</target>
</trans-unit>
</body>
</file>
</xliff>
@@ -366,6 +366,10 @@
<source>This value should be between {{ min }} and {{ max }}.</source>
<target>Значение должно быть между {{ min }} и {{ max }}.</target>
</trans-unit>
<trans-unit id="95">
<source>This value is not a valid hostname.</source>
<target>Значение не является корректным именем хоста.</target>
</trans-unit>
</body>
</file>
</xliff>
@@ -0,0 +1,200 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Validator\Tests\Constraints;

use Symfony\Component\Validator\Constraints\Hostname;
use Symfony\Component\Validator\Constraints\HostnameValidator;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;

/**
* @author Dmitrii Poddubnyi <dpoddubny@gmail.com>
*/
class HostnameValidatorTest extends ConstraintValidatorTestCase
{
public function testNullIsValid()
{
$this->validator->validate(null, new Hostname());

$this->assertNoViolation();
}

public function testEmptyStringIsValid()
{
$this->validator->validate('', new Hostname());

$this->assertNoViolation();
}

public function testExpectsStringCompatibleType()
{
$this->expectException(\Symfony\Component\Validator\Exception\UnexpectedValueException::class);

$this->validator->validate(new \stdClass(), new Hostname());
}

/**
* @dataProvider getValidMultilevelDomains
*/
public function testValidTldDomainsPassValidationIfTldRequired($domain)
{
$this->validator->validate($domain, new Hostname());

$this->assertNoViolation();
}

/**
* @dataProvider getValidMultilevelDomains
*/
public function testValidTldDomainsPassValidationIfTldNotRequired($domain)
{
$this->validator->validate($domain, new Hostname(['requireTld' => false]));

$this->assertNoViolation();
}

public function getValidMultilevelDomains()
{
return [
['symfony.com'],
['example.co.uk'],
['example.fr'],
['example.com'],
['xn--diseolatinoamericano-66b.com'],
['xn--ggle-0nda.com'],
['www.xn--simulateur-prt-2kb.fr'],
[sprintf('%s.com', str_repeat('a', 20))],
];
}

/**
* @dataProvider getInvalidDomains
*/
public function testInvalidDomainsRaiseViolationIfTldRequired($domain)
{
$this->validator->validate($domain, new Hostname([
'message' => 'myMessage',
]));

$this->buildViolation('myMessage')
->setParameter('{{ value }}', '"'.$domain.'"')
->setCode(Hostname::INVALID_HOSTNAME_ERROR)
->assertRaised();
}

/**
* @dataProvider getInvalidDomains
*/
public function testInvalidDomainsRaiseViolationIfTldNotRequired($domain)
{
$this->validator->validate($domain, new Hostname([
'message' => 'myMessage',
'requireTld' => false,
]));

$this->buildViolation('myMessage')
->setParameter('{{ value }}', '"'.$domain.'"')
->setCode(Hostname::INVALID_HOSTNAME_ERROR)
->assertRaised();
}

public function getInvalidDomains()
{
return [
['acme..com'],
['qq--.com'],
['-example.com'],
['example-.com'],
[sprintf('%s.com', str_repeat('a', 300))],
];
}

/**
* @dataProvider getReservedDomains
*/
public function testReservedDomainsPassValidationIfTldNotRequired($domain)
{
$this->validator->validate($domain, new Hostname(['requireTld' => false]));

$this->assertNoViolation();
}

/**
* @dataProvider getReservedDomains
*/
public function testReservedDomainsRaiseViolationIfTldRequired($domain)
{
$this->validator->validate($domain, new Hostname([
'message' => 'myMessage',
'requireTld' => true,
]));

$this->buildViolation('myMessage')
->setParameter('{{ value }}', '"'.$domain.'"')
->setCode(Hostname::INVALID_HOSTNAME_ERROR)
->assertRaised();
}

public function getReservedDomains()
{
return [
['example'],
['foo.example'],
['invalid'],
['bar.invalid'],
['localhost'],
['lol.localhost'],
['test'],
['abc.test'],
];
}

/**
* @dataProvider getTopLevelDomains
*/
public function testTopLevelDomainsPassValidationIfTldNotRequired($domain)
{
$this->validator->validate($domain, new Hostname(['requireTld' => false]));

$this->assertNoViolation();
}

/**
* @dataProvider getTopLevelDomains
*/
public function testTopLevelDomainsRaiseViolationIfTldRequired($domain)
{
$this->validator->validate($domain, new Hostname([
'message' => 'myMessage',
'requireTld' => true,
]));

$this->buildViolation('myMessage')
->setParameter('{{ value }}', '"'.$domain.'"')
->setCode(Hostname::INVALID_HOSTNAME_ERROR)
->assertRaised();
}

public function getTopLevelDomains()
{
return [
['com'],
['net'],
['org'],
['etc'],
];
}

protected function createValidator()
{
return new HostnameValidator();
}
}

0 comments on commit 7dae1ca

Please sign in to comment.
You can’t perform that action at this time.