Skip to content

Commit

Permalink
[Validator] Add a NotSuspicious constraint to validate a string is …
Browse files Browse the repository at this point in the history
…not a spoof attempt
  • Loading branch information
MatTheCat committed Feb 8, 2023
1 parent ea7cc20 commit f3a7774
Show file tree
Hide file tree
Showing 4 changed files with 233 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/Symfony/Component/Validator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ CHANGELOG
* Add method `getConstraint()` to `ConstraintViolationInterface`
* Add `Uuid::TIME_BASED_VERSIONS` to match that a UUID being validated embeds a timestamp
* Add the `pattern` parameter in violations of the `Regex` constraint
* Add a `NotSuspicious` constraint to validate a string is not a spoof attempt

6.2
---
Expand Down
68 changes: 68 additions & 0 deletions src/Symfony/Component/Validator/Constraints/NotSuspicious.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?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\Exception\LogicException;

/**
* @Annotation
*
* @Target({"PROPERTY", "METHOD", "ANNOTATION"})
*
* @author Mathieu Lechat <mathieu.lechat@les-tilleuls.coop>
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class NotSuspicious extends Constraint
{
/**
* Check that a string satisfies the requirements for the specified restriction level
* (by default {@see \Spoofchecker::HIGHLY_RESTRICTIVE}).
*/
public const CHECK_RESTRICTION_LEVEL = 16;

/**
* Check a string for the presence of invisible characters such as zero-width spaces,
* or character sequences that are likely not to display such as multiple occurrences of the same non-spacing mark.
*/
public const CHECK_INVISIBLE = \Spoofchecker::INVISIBLE;

/** Check a string contains only characters allowed by the configured profile. */
public const CHECK_CHAR_LIMIT = \Spoofchecker::CHAR_LIMIT;

/**
* Check that a string does not mix numbers from different numbering systems;
* for example “8” (Digit Eight) and “৪” (Bengali Digit Four).
*/
public const CHECK_MIXED_NUMBERS = 128;

/**
* Check that a string does not have a combining character following a character in which it would be hidden;
* for example “i” (Latin Small Letter I) followed by a U+0307 (Combining Dot Above).
*/
public const CHECK_HIDDEN_OVERLAY = 256;

public $message = 'This value is suspicious.';
public int $restrictionLevel = \Spoofchecker::HIGHLY_RESTRICTIVE;
public int $checks = self::CHECK_RESTRICTION_LEVEL | self::CHECK_INVISIBLE | self::CHECK_CHAR_LIMIT | self::CHECK_MIXED_NUMBERS | self::CHECK_HIDDEN_OVERLAY;
public array $profileLocales = [];
public bool $addCurrentLocaleToProfile = true;

public function __construct(mixed $options = null, array $groups = null, mixed $payload = null)
{
if (!class_exists(\Spoofchecker::class)) {
throw new LogicException('The intl extension is required to use the NotSuspicious constraint.');
}

parent::__construct($options, $groups, $payload);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?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;
use Symfony\Contracts\Translation\LocaleAwareInterface;

/**
* @author Mathieu Lechat <mathieu.lechat@les-tilleuls.coop>
*/
class NotSuspiciousValidator extends ConstraintValidator implements LocaleAwareInterface
{
private string $locale;

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

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

if (!\is_scalar($value) && !$value instanceof \Stringable) {
throw new UnexpectedValueException($value, 'string');
}

$value = (string) $value;
if ('' === $value) {
return;
}

$checker = new \Spoofchecker();

$checker->setRestrictionLevel($constraint->restrictionLevel);

$allowedLocales = $constraint->profileLocales;
if ($constraint->addCurrentLocaleToProfile) {
$allowedLocales[] = $this->locale;
}
$checker->setAllowedLocales(implode(',', $allowedLocales));

$checker->setChecks($constraint->checks);

if (!$checker->isSuspicious($value)) {
return;
}

$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $this->formatValue($value))
->addViolation()
;
}

public function setLocale(string $locale)
{
$this->locale = $locale;
}

public function getLocale(): string
{
return $this->locale;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?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\NotSuspicious;
use Symfony\Component\Validator\Constraints\NotSuspiciousValidator;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;

/**
* @requires extension intl
*
* @extends ConstraintValidatorTestCase<NotSuspiciousValidator>
*/
class NotSuspiciousValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): NotSuspiciousValidator
{
$validator = new NotSuspiciousValidator();
$validator->setLocale(\Locale::getDefault());

return $validator;
}

public function testNonSuspiciousStrings()
{
$this->validator->validate('à', new NotSuspicious());

$this->assertNoViolation();
}

/**
* @dataProvider provideSuspiciousStrings
*/
public function testSuspiciousStrings(string $string, array $options)
{
$this->validator->validate($string, new NotSuspicious(['message' => 'myMessage'] + $options));

$this->buildViolation('myMessage')
->setParameter('{{ value }}', '"'.$string.'"')
->assertRaised();
}

public static function provideSuspiciousStrings(): iterable
{
yield 'Fails restriction level check because of character outside ASCII range' => ['à',
['restrictionLevel' => \Spoofchecker::ASCII],
];
yield 'Fails restriction level check because of mixed-script string' => ['àㄚ', [
'restrictionLevel' => \Spoofchecker::SINGLE_SCRIPT_RESTRICTIVE,
'profileLocales' => ['zh_Hant_TW'],
]];
yield 'Fails restriction level check because of disallowed Armenian script' => ['àԱ', [
'restrictionLevel' => \Spoofchecker::HIGHLY_RESTRICTIVE,
'profileLocales' => ['hy_AM'],
]];
yield 'Fails restriction level check because of disallowed Greek script' => ['àπ', [
'restrictionLevel' => \Spoofchecker::MODERATELY_RESTRICTIVE,
'profileLocales' => ['el_GR'],
]];
yield 'Fails restriction level check because of Greek script absent from profile' => ['àπ', [
'checks' => NotSuspicious::CHECK_RESTRICTION_LEVEL,
'restrictionLevel' => \Spoofchecker::MINIMALLY_RESTRICTIVE,
]];

yield 'Fails INVISIBLE check because of duplicated non-spacing mark' => ['à̀', [
'checks' => NotSuspicious::CHECK_INVISIBLE,
]];
yield 'Fails CHAR_LIMIT check because of Greek script absent from profile' => ['àπ', [
'checks' => NotSuspicious::CHECK_CHAR_LIMIT,
]];
yield 'Fails MIXED_NUMBERS check because of different numbering systems' => ['8৪', [
'checks' => NotSuspicious::CHECK_MIXED_NUMBERS,
]];
yield 'Fails HIDDEN_OVERLAY check because of hidden combining character' => ['i̇', [
'checks' => NotSuspicious::CHECK_HIDDEN_OVERLAY,
]];
}
}

0 comments on commit f3a7774

Please sign in to comment.