Skip to content

Commit 12f901f

Browse files
committed
[Validator] Add a NoSuspiciousCharacters constraint to validate a string is not a spoof attempt
1 parent 46f3879 commit 12f901f

File tree

6 files changed

+397
-1
lines changed

6 files changed

+397
-1
lines changed

src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
1717
use Symfony\Component\Validator\Constraints\EmailValidator;
1818
use Symfony\Component\Validator\Constraints\ExpressionValidator;
19+
use Symfony\Component\Validator\Constraints\NoSuspiciousCharactersValidator;
1920
use Symfony\Component\Validator\Constraints\NotCompromisedPasswordValidator;
2021
use Symfony\Component\Validator\Constraints\WhenValidator;
2122
use Symfony\Component\Validator\ContainerConstraintValidatorFactory;
@@ -102,6 +103,12 @@
102103
'alias' => WhenValidator::class,
103104
])
104105

106+
->set('validator.no_suspicious_characters', NoSuspiciousCharactersValidator::class)
107+
->args([param('kernel.enabled_locales')])
108+
->tag('validator.constraint_validator', [
109+
'alias' => NoSuspiciousCharactersValidator::class,
110+
])
111+
105112
->set('validator.property_info_loader', PropertyInfoLoader::class)
106113
->args([
107114
service('property_info'),

src/Symfony/Bundle/FrameworkBundle/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"symfony/string": "^5.4|^6.0",
6161
"symfony/translation": "^5.4|^6.0",
6262
"symfony/twig-bundle": "^5.4|^6.0",
63-
"symfony/validator": "^5.4|^6.0",
63+
"symfony/validator": "^6.3",
6464
"symfony/workflow": "^5.4|^6.0",
6565
"symfony/yaml": "^5.4|^6.0",
6666
"symfony/property-info": "^5.4|^6.0",

src/Symfony/Component/Validator/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CHANGELOG
77
* Add method `getConstraint()` to `ConstraintViolationInterface`
88
* Add `Uuid::TIME_BASED_VERSIONS` to match that a UUID being validated embeds a timestamp
99
* Add the `pattern` parameter in violations of the `Regex` constraint
10+
* Add a `NoSuspiciousCharacters` constraint to validate a string is not a spoofing attempt
1011

1112
6.2
1213
---
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Constraint;
15+
use Symfony\Component\Validator\Exception\LogicException;
16+
17+
/**
18+
* @Annotation
19+
* @Target({"PROPERTY", "METHOD", "ANNOTATION"})
20+
*
21+
* @author Mathieu Lechat <mathieu.lechat@les-tilleuls.coop>
22+
*/
23+
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
24+
class NoSuspiciousCharacters extends Constraint
25+
{
26+
public const RESTRICTION_LEVEL_ERROR = '1ece07dc-dca2-45f1-ba47-8d7dc3a12774';
27+
public const INVISIBLE_ERROR = '6ed60e6c-179b-4e93-8a6c-667d85c6de5e';
28+
public const MIXED_NUMBERS_ERROR = '9f01fc26-3bc4-44b1-a6b1-c08e2412053a';
29+
public const HIDDEN_OVERLAY_ERROR = '56380dc5-0476-4f04-bbaa-b68cd1c2d974';
30+
31+
protected const ERROR_NAMES = [
32+
self::RESTRICTION_LEVEL_ERROR => 'RESTRICTION_LEVEL_ERROR',
33+
self::INVISIBLE_ERROR => 'INVISIBLE_ERROR',
34+
self::MIXED_NUMBERS_ERROR => 'MIXED_NUMBERS_ERROR',
35+
self::HIDDEN_OVERLAY_ERROR => 'INVALID_CASE_ERROR',
36+
];
37+
38+
/**
39+
* Check a string for the presence of invisible characters such as zero-width spaces,
40+
* or character sequences that are likely not to display such as multiple occurrences of the same non-spacing mark.
41+
*/
42+
public const CHECK_INVISIBLE = 32;
43+
44+
/**
45+
* Check that a string does not mix numbers from different numbering systems;
46+
* for example “8” (Digit Eight) and “৪” (Bengali Digit Four).
47+
*/
48+
public const CHECK_MIXED_NUMBERS = 128;
49+
50+
/**
51+
* Check that a string does not have a combining character following a character in which it would be hidden;
52+
* for example “i” (Latin Small Letter I) followed by a U+0307 (Combining Dot Above).
53+
*/
54+
public const CHECK_HIDDEN_OVERLAY = 256;
55+
56+
/** @see https://unicode.org/reports/tr39/#ascii_only */
57+
public const RESTRICTION_LEVEL_ASCII = 268435456;
58+
59+
/** @see https://unicode.org/reports/tr39/#single_script */
60+
public const RESTRICTION_LEVEL_SINGLE_SCRIPT = 536870912;
61+
62+
/** @see https://unicode.org/reports/tr39/#highly_restrictive */
63+
public const RESTRICTION_LEVEL_HIGH = 805306368;
64+
65+
/** @see https://unicode.org/reports/tr39/#moderately_restrictive */
66+
public const RESTRICTION_LEVEL_MODERATE = 1073741824;
67+
68+
/** @see https://unicode.org/reports/tr39/#minimally_restrictive */
69+
public const RESTRICTION_LEVEL_MINIMAL = 1342177280;
70+
71+
/** @see https://unicode.org/reports/tr39/#unrestricted */
72+
public const RESTRICTION_LEVEL_NONE = 1610612736;
73+
74+
public string $restrictionLevelMessage = 'This value contains characters that are not allowed by the current restriction-level.';
75+
public string $invisibleMessage = 'Using invisible characters is not allowed.';
76+
public string $mixedNumbersMessage = 'Mixing numbers from different scripts is not allowed.';
77+
public string $hiddenOverlayMessage = 'Using hidden overlay characters is not allowed.';
78+
79+
public int $checks = self::CHECK_INVISIBLE | self::CHECK_MIXED_NUMBERS | self::CHECK_HIDDEN_OVERLAY;
80+
public ?int $restrictionLevel = null;
81+
public ?array $locales = null;
82+
83+
/**
84+
* @param int-mask-of<self::CHECK_*>|null $checks
85+
* @param self::RESTRICTION_LEVEL_*|null $restrictionLevel
86+
*/
87+
public function __construct(
88+
array $options = null,
89+
string $restrictionLevelMessage = null,
90+
string $invisibleMessage = null,
91+
string $mixedNumbersMessage = null,
92+
string $hiddenOverlayMessage = null,
93+
int $checks = null,
94+
int $restrictionLevel = null,
95+
array $locales = null,
96+
array $groups = null,
97+
mixed $payload = null
98+
) {
99+
if (!class_exists(\Spoofchecker::class)) {
100+
throw new LogicException('The intl extension is required to use the NoSuspiciousCharacters constraint.');
101+
}
102+
103+
parent::__construct($options, $groups, $payload);
104+
105+
$this->restrictionLevelMessage ??= $restrictionLevelMessage;
106+
$this->invisibleMessage ??= $invisibleMessage;
107+
$this->mixedNumbersMessage ??= $mixedNumbersMessage;
108+
$this->hiddenOverlayMessage ??= $hiddenOverlayMessage;
109+
$this->checks ??= $checks;
110+
$this->restrictionLevel ??= $restrictionLevel;
111+
$this->locales ??= $locales;
112+
}
113+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Constraint;
15+
use Symfony\Component\Validator\ConstraintValidator;
16+
use Symfony\Component\Validator\Exception\LogicException;
17+
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
18+
use Symfony\Component\Validator\Exception\UnexpectedValueException;
19+
20+
/**
21+
* @author Mathieu Lechat <mathieu.lechat@les-tilleuls.coop>
22+
*/
23+
class NoSuspiciousCharactersValidator extends ConstraintValidator
24+
{
25+
private const CHECK_RESTRICTION_LEVEL = 16;
26+
private const CHECK_SINGLE_SCRIPT = 16;
27+
private const CHECK_CHAR_LIMIT = 64;
28+
29+
private const CHECK_ERROR = [
30+
self::CHECK_RESTRICTION_LEVEL => [
31+
'code' => NoSuspiciousCharacters::RESTRICTION_LEVEL_ERROR,
32+
'messageProperty' => 'restrictionLevelMessage',
33+
],
34+
NoSuspiciousCharacters::CHECK_INVISIBLE => [
35+
'code' => NoSuspiciousCharacters::INVISIBLE_ERROR,
36+
'messageProperty' => 'invisibleMessage',
37+
],
38+
self::CHECK_CHAR_LIMIT => [
39+
'code' => NoSuspiciousCharacters::RESTRICTION_LEVEL_ERROR,
40+
'messageProperty' => 'restrictionLevelMessage',
41+
],
42+
NoSuspiciousCharacters::CHECK_MIXED_NUMBERS => [
43+
'code' => NoSuspiciousCharacters::MIXED_NUMBERS_ERROR,
44+
'messageProperty' => 'mixedNumbersMessage',
45+
],
46+
NoSuspiciousCharacters::CHECK_HIDDEN_OVERLAY => [
47+
'code' => NoSuspiciousCharacters::HIDDEN_OVERLAY_ERROR,
48+
'messageProperty' => 'hiddenOverlayMessage',
49+
],
50+
];
51+
52+
/**
53+
* @param string[] $defaultLocales
54+
*/
55+
public function __construct(private readonly array $defaultLocales = [])
56+
{
57+
}
58+
59+
public function validate(mixed $value, Constraint $constraint)
60+
{
61+
if (!$constraint instanceof NoSuspiciousCharacters) {
62+
throw new UnexpectedTypeException($constraint, NoSuspiciousCharacters::class);
63+
}
64+
65+
if (null === $value || '' === $value) {
66+
return;
67+
}
68+
69+
if (!\is_scalar($value) && !$value instanceof \Stringable) {
70+
throw new UnexpectedValueException($value, 'string');
71+
}
72+
73+
if ('' === $value = (string) $value) {
74+
return;
75+
}
76+
77+
$checker = new \Spoofchecker();
78+
$checks = $constraint->checks;
79+
80+
if (method_exists($checker, 'setRestrictionLevel')) {
81+
$checks |= self::CHECK_RESTRICTION_LEVEL;
82+
$checker->setRestrictionLevel($constraint->restrictionLevel ?? NoSuspiciousCharacters::RESTRICTION_LEVEL_MODERATE);
83+
} elseif (NoSuspiciousCharacters::RESTRICTION_LEVEL_MINIMAL === $constraint->restrictionLevel) {
84+
$checks |= self::CHECK_CHAR_LIMIT;
85+
} elseif (NoSuspiciousCharacters::RESTRICTION_LEVEL_SINGLE_SCRIPT === $constraint->restrictionLevel) {
86+
$checks |= self::CHECK_SINGLE_SCRIPT | self::CHECK_CHAR_LIMIT;
87+
} elseif ($constraint->restrictionLevel) {
88+
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.');
89+
} else {
90+
$checks |= self::CHECK_SINGLE_SCRIPT;
91+
}
92+
93+
$checker->setAllowedLocales(implode(',', $constraint->locales ?? $this->defaultLocales));
94+
95+
$checker->setChecks($checks);
96+
97+
if (!$checker->isSuspicious($value)) {
98+
return;
99+
}
100+
101+
foreach (self::CHECK_ERROR as $check => $error) {
102+
if (!($checks & $check)) {
103+
continue;
104+
}
105+
106+
$checker->setChecks($check);
107+
108+
if (!$checker->isSuspicious($value)) {
109+
continue;
110+
}
111+
112+
$this->context->buildViolation($constraint->{$error['messageProperty']})
113+
->setParameter('{{ value }}', $this->formatValue($value))
114+
->setCode($error['code'])
115+
->addViolation()
116+
;
117+
}
118+
}
119+
}

0 commit comments

Comments
 (0)