Skip to content

Commit 5210070

Browse files
committed
[Validator] Add a NoSuspiciousCharacters constraint to validate a string is not a spoof attempt
1 parent 133797d commit 5210070

File tree

4 files changed

+389
-0
lines changed

4 files changed

+389
-0
lines changed

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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Tests\Constraints;
13+
14+
use Symfony\Component\Validator\Constraints\NoSuspiciousCharacters;
15+
use Symfony\Component\Validator\Constraints\NoSuspiciousCharactersValidator;
16+
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
17+
18+
/**
19+
* @requires extension intl
20+
*
21+
* @extends ConstraintValidatorTestCase<NoSuspiciousCharactersValidator>
22+
*/
23+
class NoSuspiciousCharactersValidatorTest extends ConstraintValidatorTestCase
24+
{
25+
protected function createValidator(): NoSuspiciousCharactersValidator
26+
{
27+
return new NoSuspiciousCharactersValidator();
28+
}
29+
30+
/**
31+
* @dataProvider provideNonSuspiciousStrings
32+
*/
33+
public function testNonSuspiciousStrings(string $string, array $options = [])
34+
{
35+
$this->validator->validate($string, new NoSuspiciousCharacters($options));
36+
37+
$this->assertNoViolation();
38+
}
39+
40+
public static function provideNonSuspiciousStrings(): iterable
41+
{
42+
yield 'Characters from Common script can only fail RESTRICTION_LEVEL_ASCII' => [
43+
'I ❤️ Unicode',
44+
['restrictionLevel' => NoSuspiciousCharacters::RESTRICTION_LEVEL_SINGLE_SCRIPT],
45+
];
46+
47+
yield 'RESTRICTION_LEVEL_MINIMAL cannot fail without configured locales' => [
48+
'àㄚԱπ৪',
49+
[
50+
'restrictionLevel' => NoSuspiciousCharacters::RESTRICTION_LEVEL_MINIMAL,
51+
'locales' => [],
52+
],
53+
];
54+
}
55+
56+
/**
57+
* @dataProvider provideSuspiciousStrings
58+
*/
59+
public function testSuspiciousStrings(string $string, array $options, string $errorCode, string $errorMessage)
60+
{
61+
$this->validator->validate($string, new NoSuspiciousCharacters($options));
62+
63+
$this->buildViolation($errorMessage)
64+
->setCode($errorCode)
65+
->setParameter('{{ value }}', '"'.$string.'"')
66+
->assertRaised();
67+
}
68+
69+
public static function provideSuspiciousStrings(): iterable
70+
{
71+
yield 'Fails RESTRICTION_LEVEL check because of character outside ASCII range' => [
72+
'à',
73+
['restrictionLevel' => NoSuspiciousCharacters::RESTRICTION_LEVEL_ASCII],
74+
NoSuspiciousCharacters::RESTRICTION_LEVEL_ERROR,
75+
'This value contains characters that are not allowed by the current restriction-level.',
76+
];
77+
78+
yield 'Fails RESTRICTION_LEVEL check because of mixed-script string' => [
79+
'àㄚ',
80+
[
81+
'restrictionLevel' => NoSuspiciousCharacters::RESTRICTION_LEVEL_SINGLE_SCRIPT,
82+
'locales' => ['en', 'zh_Hant_TW'],
83+
],
84+
NoSuspiciousCharacters::RESTRICTION_LEVEL_ERROR,
85+
'This value contains characters that are not allowed by the current restriction-level.',
86+
];
87+
88+
yield 'Fails RESTRICTION_LEVEL check because RESTRICTION_LEVEL_HIGH disallows Armenian script' => [
89+
'àԱ',
90+
[
91+
'restrictionLevel' => NoSuspiciousCharacters::RESTRICTION_LEVEL_HIGH,
92+
'locales' => ['en', 'hy_AM'],
93+
],
94+
NoSuspiciousCharacters::RESTRICTION_LEVEL_ERROR,
95+
'This value contains characters that are not allowed by the current restriction-level.',
96+
];
97+
98+
yield 'Fails RESTRICTION_LEVEL check because RESTRICTION_LEVEL_MODERATE disallows Greek script' => [
99+
'àπ',
100+
[
101+
'restrictionLevel' => NoSuspiciousCharacters::RESTRICTION_LEVEL_MODERATE,
102+
'locales' => ['en', 'el_GR'],
103+
],
104+
NoSuspiciousCharacters::RESTRICTION_LEVEL_ERROR,
105+
'This value contains characters that are not allowed by the current restriction-level.',
106+
];
107+
108+
yield 'Fails RESTRICTION_LEVEL check because of characters missing from the configured locales’ scripts' => [
109+
'àπ',
110+
[
111+
'restrictionLevel' => NoSuspiciousCharacters::RESTRICTION_LEVEL_MINIMAL,
112+
'locales' => ['en'],
113+
],
114+
NoSuspiciousCharacters::RESTRICTION_LEVEL_ERROR,
115+
'This value contains characters that are not allowed by the current restriction-level.',
116+
];
117+
118+
yield 'Fails INVISIBLE check because of duplicated non-spacing mark' => [
119+
'à̀',
120+
[
121+
'checks' => NoSuspiciousCharacters::CHECK_INVISIBLE,
122+
],
123+
NoSuspiciousCharacters::INVISIBLE_ERROR,
124+
'Using invisible characters is not allowed.',
125+
];
126+
127+
yield 'Fails MIXED_NUMBERS check because of different numbering systems' => [
128+
'8৪',
129+
[
130+
'checks' => NoSuspiciousCharacters::CHECK_MIXED_NUMBERS,
131+
],
132+
NoSuspiciousCharacters::MIXED_NUMBERS_ERROR,
133+
'Mixing numbers from different scripts is not allowed.',
134+
];
135+
136+
yield 'Fails HIDDEN_OVERLAY check because of hidden combining character' => [
137+
'',
138+
[
139+
'checks' => NoSuspiciousCharacters::CHECK_HIDDEN_OVERLAY,
140+
],
141+
NoSuspiciousCharacters::HIDDEN_OVERLAY_ERROR,
142+
'Using hidden overlay characters is not allowed.',
143+
];
144+
}
145+
146+
public function testConstants()
147+
{
148+
$this->assertSame(\Spoofchecker::INVISIBLE, NoSuspiciousCharacters::CHECK_INVISIBLE);
149+
$this->assertSame(\Spoofchecker::ASCII, NoSuspiciousCharacters::RESTRICTION_LEVEL_ASCII);
150+
$this->assertSame(\Spoofchecker::SINGLE_SCRIPT_RESTRICTIVE, NoSuspiciousCharacters::RESTRICTION_LEVEL_SINGLE_SCRIPT);
151+
$this->assertSame(\Spoofchecker::HIGHLY_RESTRICTIVE, NoSuspiciousCharacters::RESTRICTION_LEVEL_HIGH);
152+
$this->assertSame(\Spoofchecker::MODERATELY_RESTRICTIVE, NoSuspiciousCharacters::RESTRICTION_LEVEL_MODERATE);
153+
$this->assertSame(\Spoofchecker::MINIMALLY_RESTRICTIVE, NoSuspiciousCharacters::RESTRICTION_LEVEL_MINIMAL);
154+
$this->assertSame(\Spoofchecker::UNRESTRICTIVE, NoSuspiciousCharacters::RESTRICTION_LEVEL_NONE);
155+
}
156+
}

0 commit comments

Comments
 (0)