Skip to content

Commit

Permalink
feature #49300 [Validator] Add a NoSuspiciousCharacters constraint …
Browse files Browse the repository at this point in the history
…to validate a string is not suspicious (MatTheCat)

This PR was merged into the 6.3 branch.

Discussion
----------

[Validator] Add a `NoSuspiciousCharacters` constraint to validate a string is not suspicious

| Q             | A
| ------------- | ---
| Branch?       | 6.3
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | Fix #49268
| License       | MIT
| Doc PR        | symfony/symfony-docs#17897

Leverage `Spoofchecker::isSuspicious` in a new constraint.

Commits
-------

12f901f [Validator] Add a `NoSuspiciousCharacters` constraint to validate a string is not a spoof attempt
  • Loading branch information
nicolas-grekas committed Feb 21, 2023
2 parents 72cb6a6 + 12f901f commit 8fb797c
Show file tree
Hide file tree
Showing 6 changed files with 397 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\Validator\Constraints\EmailValidator;
use Symfony\Component\Validator\Constraints\ExpressionValidator;
use Symfony\Component\Validator\Constraints\NoSuspiciousCharactersValidator;
use Symfony\Component\Validator\Constraints\NotCompromisedPasswordValidator;
use Symfony\Component\Validator\Constraints\WhenValidator;
use Symfony\Component\Validator\ContainerConstraintValidatorFactory;
Expand Down Expand Up @@ -102,6 +103,12 @@
'alias' => WhenValidator::class,
])

->set('validator.no_suspicious_characters', NoSuspiciousCharactersValidator::class)
->args([param('kernel.enabled_locales')])
->tag('validator.constraint_validator', [
'alias' => NoSuspiciousCharactersValidator::class,
])

->set('validator.property_info_loader', PropertyInfoLoader::class)
->args([
service('property_info'),
Expand Down
2 changes: 1 addition & 1 deletion src/Symfony/Bundle/FrameworkBundle/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"symfony/string": "^5.4|^6.0",
"symfony/translation": "^5.4|^6.0",
"symfony/twig-bundle": "^5.4|^6.0",
"symfony/validator": "^5.4|^6.0",
"symfony/validator": "^6.3",
"symfony/workflow": "^5.4|^6.0",
"symfony/yaml": "^5.4|^6.0",
"symfony/property-info": "^5.4|^6.0",
Expand Down
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 `NoSuspiciousCharacters` constraint to validate a string is not a spoofing attempt

6.2
---
Expand Down
113 changes: 113 additions & 0 deletions src/Symfony/Component/Validator/Constraints/NoSuspiciousCharacters.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?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 NoSuspiciousCharacters extends Constraint
{
public const RESTRICTION_LEVEL_ERROR = '1ece07dc-dca2-45f1-ba47-8d7dc3a12774';
public const INVISIBLE_ERROR = '6ed60e6c-179b-4e93-8a6c-667d85c6de5e';
public const MIXED_NUMBERS_ERROR = '9f01fc26-3bc4-44b1-a6b1-c08e2412053a';
public const HIDDEN_OVERLAY_ERROR = '56380dc5-0476-4f04-bbaa-b68cd1c2d974';

protected const ERROR_NAMES = [
self::RESTRICTION_LEVEL_ERROR => 'RESTRICTION_LEVEL_ERROR',
self::INVISIBLE_ERROR => 'INVISIBLE_ERROR',
self::MIXED_NUMBERS_ERROR => 'MIXED_NUMBERS_ERROR',
self::HIDDEN_OVERLAY_ERROR => 'INVALID_CASE_ERROR',
];

/**
* 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 = 32;

/**
* 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;

/** @see https://unicode.org/reports/tr39/#ascii_only */
public const RESTRICTION_LEVEL_ASCII = 268435456;

/** @see https://unicode.org/reports/tr39/#single_script */
public const RESTRICTION_LEVEL_SINGLE_SCRIPT = 536870912;

/** @see https://unicode.org/reports/tr39/#highly_restrictive */
public const RESTRICTION_LEVEL_HIGH = 805306368;

/** @see https://unicode.org/reports/tr39/#moderately_restrictive */
public const RESTRICTION_LEVEL_MODERATE = 1073741824;

/** @see https://unicode.org/reports/tr39/#minimally_restrictive */
public const RESTRICTION_LEVEL_MINIMAL = 1342177280;

/** @see https://unicode.org/reports/tr39/#unrestricted */
public const RESTRICTION_LEVEL_NONE = 1610612736;

public string $restrictionLevelMessage = 'This value contains characters that are not allowed by the current restriction-level.';
public string $invisibleMessage = 'Using invisible characters is not allowed.';
public string $mixedNumbersMessage = 'Mixing numbers from different scripts is not allowed.';
public string $hiddenOverlayMessage = 'Using hidden overlay characters is not allowed.';

public int $checks = self::CHECK_INVISIBLE | self::CHECK_MIXED_NUMBERS | self::CHECK_HIDDEN_OVERLAY;
public ?int $restrictionLevel = null;
public ?array $locales = null;

/**
* @param int-mask-of<self::CHECK_*>|null $checks
* @param self::RESTRICTION_LEVEL_*|null $restrictionLevel
*/
public function __construct(
array $options = null,
string $restrictionLevelMessage = null,
string $invisibleMessage = null,
string $mixedNumbersMessage = null,
string $hiddenOverlayMessage = null,
int $checks = null,
int $restrictionLevel = null,
array $locales = null,
array $groups = null,
mixed $payload = null
) {
if (!class_exists(\Spoofchecker::class)) {
throw new LogicException('The intl extension is required to use the NoSuspiciousCharacters constraint.');
}

parent::__construct($options, $groups, $payload);

$this->restrictionLevelMessage ??= $restrictionLevelMessage;
$this->invisibleMessage ??= $invisibleMessage;
$this->mixedNumbersMessage ??= $mixedNumbersMessage;
$this->hiddenOverlayMessage ??= $hiddenOverlayMessage;
$this->checks ??= $checks;
$this->restrictionLevel ??= $restrictionLevel;
$this->locales ??= $locales;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?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\LogicException;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

/**
* @author Mathieu Lechat <mathieu.lechat@les-tilleuls.coop>
*/
class NoSuspiciousCharactersValidator extends ConstraintValidator
{
private const CHECK_RESTRICTION_LEVEL = 16;
private const CHECK_SINGLE_SCRIPT = 16;
private const CHECK_CHAR_LIMIT = 64;

private const CHECK_ERROR = [
self::CHECK_RESTRICTION_LEVEL => [
'code' => NoSuspiciousCharacters::RESTRICTION_LEVEL_ERROR,
'messageProperty' => 'restrictionLevelMessage',
],
NoSuspiciousCharacters::CHECK_INVISIBLE => [
'code' => NoSuspiciousCharacters::INVISIBLE_ERROR,
'messageProperty' => 'invisibleMessage',
],
self::CHECK_CHAR_LIMIT => [
'code' => NoSuspiciousCharacters::RESTRICTION_LEVEL_ERROR,
'messageProperty' => 'restrictionLevelMessage',
],
NoSuspiciousCharacters::CHECK_MIXED_NUMBERS => [
'code' => NoSuspiciousCharacters::MIXED_NUMBERS_ERROR,
'messageProperty' => 'mixedNumbersMessage',
],
NoSuspiciousCharacters::CHECK_HIDDEN_OVERLAY => [
'code' => NoSuspiciousCharacters::HIDDEN_OVERLAY_ERROR,
'messageProperty' => 'hiddenOverlayMessage',
],
];

/**
* @param string[] $defaultLocales
*/
public function __construct(private readonly array $defaultLocales = [])
{
}

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

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

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

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

$checker = new \Spoofchecker();
$checks = $constraint->checks;

if (method_exists($checker, 'setRestrictionLevel')) {
$checks |= self::CHECK_RESTRICTION_LEVEL;
$checker->setRestrictionLevel($constraint->restrictionLevel ?? NoSuspiciousCharacters::RESTRICTION_LEVEL_MODERATE);
} elseif (NoSuspiciousCharacters::RESTRICTION_LEVEL_MINIMAL === $constraint->restrictionLevel) {
$checks |= self::CHECK_CHAR_LIMIT;
} elseif (NoSuspiciousCharacters::RESTRICTION_LEVEL_SINGLE_SCRIPT === $constraint->restrictionLevel) {
$checks |= self::CHECK_SINGLE_SCRIPT | self::CHECK_CHAR_LIMIT;
} elseif ($constraint->restrictionLevel) {
throw new LogicException('You can only use one of RESTRICTION_LEVEL_NONE, RESTRICTION_LEVEL_MINIMAL or RESTRICTION_LEVEL_SINGLE_SCRIPT with intl compiled against ICU < 58.');
} else {
$checks |= self::CHECK_SINGLE_SCRIPT;
}

$checker->setAllowedLocales(implode(',', $constraint->locales ?? $this->defaultLocales));

$checker->setChecks($checks);

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

foreach (self::CHECK_ERROR as $check => $error) {
if (!($checks & $check)) {
continue;
}

$checker->setChecks($check);

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

$this->context->buildViolation($constraint->{$error['messageProperty']})
->setParameter('{{ value }}', $this->formatValue($value))
->setCode($error['code'])
->addViolation()
;
}
}
}

0 comments on commit 8fb797c

Please sign in to comment.