-
-
Notifications
You must be signed in to change notification settings - Fork 9.7k
[Validator] Add NoBannedWords
constraint
#49868
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
<?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\ConstraintDefinitionException; | ||
|
||
/** | ||
* @Annotation | ||
* | ||
* @Target({"PROPERTY", "METHOD", "ANNOTATION"}) | ||
* | ||
* @author Florent Morselli <florent.morselli@spomky-labs.com> | ||
*/ | ||
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] | ||
final class NoBannedWords extends Constraint | ||
{ | ||
public const BANNED_WORDS_ERROR = 'd187ff45-bf23-4331-aa87-c24a36e9b400'; | ||
|
||
protected const ERROR_NAMES = [ | ||
self::BANNED_WORDS_ERROR => 'BANNED_WORDS_ERROR', | ||
]; | ||
|
||
public string $message = 'The value contains the following banned words: {{ wordList }}.'; | ||
|
||
/** | ||
* @var array<string> | ||
*/ | ||
public array $dictionary = []; | ||
|
||
/** | ||
* @param array<string> $dictionary | ||
*/ | ||
public function __construct(array $dictionary = [], mixed $options = null, array $groups = null, mixed $payload = null) | ||
{ | ||
array_walk($options['dictionary'], static function (mixed $value): void { | ||
if (!\is_string($value)) { | ||
throw new ConstraintDefinitionException(sprintf('The parameter "dictionary" of the "%s" constraint must be a list of strings.', static::class)); | ||
} | ||
}); | ||
$options['dictionary'] = $dictionary; | ||
parent::__construct($options, $groups, $payload); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
<?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; | ||
|
||
final class NoBannedWordsValidator extends ConstraintValidator | ||
{ | ||
private const LEET_MAP = [ | ||
'a' => '(a|4|/\\|@|\^|aye|∂|/\-\\|\|\-\\|q)', | ||
'b' => '(b|8|6|13|\|3|ß|P\>|\|\:|\!3|\(3|/3|\)3)', | ||
'c' => '(c|\(|¢|\<|\[|©)', | ||
'd' => '(d|\[\)|\|o|\)|I\>|\|\>|\?|T\)|\|\)|0|\</)', | ||
'e' => '(e|3|&|€|£|є|ë|\[\-|\|\=\-)', | ||
'f' => '(f|\|\=|ƒ|\|\#|ph|/\=)', | ||
'g' => '(g|6|&|\(_\+|9|C\-|gee|\(γ,)', | ||
'h' => '(h|\#|/\-/|\[\-\]|\]\-\[|\)\-\(|\(\-\)|\:\-\:|\|~\||\|\-\||\]~\[|\}\{|\?|\}|\-\{|hèch)', | ||
'i' => '(i|1|\!|\||\]\[|eye|3y3|\]|\:)', | ||
'j' => '(j|_\||_/|¿|\</|\(/|ʝ| ;)', | ||
'k' => '(k|X|\|\<|\|\{|ɮ|\<|\|\\“)', | ||
'l' => '(l|1|£|1_|ℓ|\||\|_|\]\[_,)', | ||
'm' => '(m|\|v\||\[V\]|\{V\}|\|\\/\||/\\/\\|\(u\)|\(V\)|\(\\/\)|/\|\\|\^\^|/\|/\||//\.|\.\\|/\^\^\\|///|\|\^\^\|)', | ||
'n' => '(n|\^/|\|V|\|\\\||/\\/|\[\\\]|\<\\\>|\{\\\}|\]\\\[|//|\^|\[\]|/V|₪)', | ||
'o' => '(o|0|\(\)|oh|\[\]|¤|°|\(\[\]\))', | ||
'p' => '(p|\|\*|\|o|\|º|\|\^\(o\)|\|\>|\|"|9|\[\]D|\|̊|\|7|\?|/\*|¶|\|D)', | ||
'q' => '(q|\(_,\)|\(\)_|0_|°\||\<\||0\.)', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All those groups should be non-capturing, to avoid reaching the limit on the number of capturing groups in PCRE There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the brackets should just be removed actually There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unfortunately, the behaviour is not the same with [] instead of (). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I asked to use non-capturing groups, not character classes |
||
'r' => '(r|2|\|\?|/2|\|\^|lz|®|\[z|12|Я|\|2|ʁ|\|²|\.\-|,\-|\|°\\)', | ||
's' => '(s|5|\$|z|§|ehs|es|_/¯)', | ||
't' => '(t|7|\+|\-\|\-|1|\'\]\[\'|†|\|²|¯\|¯)', | ||
'u' => '(u|\(_\)|\|_\||v|L\||µ|J)', | ||
'v' => '(v|\\/|1/|\|/|o\|o)', | ||
'w' => '(w|\\/\\/|vv|\'//|\\`|\\\^/|\(n\)|\\V/|\\X/|\\\|/|\\_\|_/|\\_\:_/|Ш|ɰ|`\^/|\\\./)', | ||
'x' => '(x|\>\<|Ж|\}\{|ecks|×|\)\(|8)', | ||
'y' => '(y|7|j|`/|Ψ|φ|λ|Ч|¥|\'/)', | ||
'z' => '(z|≥|2|\=/\=|7_|~/_| %|\>_|\-\\_|\'/_)', | ||
]; | ||
|
||
public function validate(mixed $value, Constraint $constraint): void | ||
{ | ||
if (!$constraint instanceof NoBannedWords) { | ||
throw new UnexpectedTypeException($constraint, NoBannedWords::class); | ||
} | ||
|
||
if (null === $value || !$constraint->dictionary) { | ||
return; | ||
} | ||
|
||
if (!\is_string($value)) { | ||
throw new UnexpectedValueException($value, 'string'); | ||
} | ||
|
||
$toL33tRegex = fn (string $data): string => implode('', array_map(fn (string $char): string => strtr($char, self::LEET_MAP), str_split($data))); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what about all characters that are not keys in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For uppercase, I use the modifier There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
This must be enforced, because it opens the door to bad behavior otherwise (and it is actually a nasty requirement as it would also add that requirement for the non-l33t banned words, which don't have this restriction otherwise) |
||
$regex = sprintf('{%s}i', implode('|', array_map($toL33tRegex(...), $constraint->dictionary))); | ||
|
||
preg_match_all($regex, $value, $matches); | ||
|
||
if (!$matches = current($matches)) { | ||
$this->context->buildViolation($constraint->message, [ | ||
'{{ matches }}' => implode(', ', $matches), | ||
'{{ dictionary }}' => implode(', ', $constraint->dictionary), | ||
]) | ||
->setCode(NoBannedWords::BANNED_WORDS_ERROR) | ||
->addViolation(); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
<?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 PHPUnit\Framework\TestCase; | ||
use Symfony\Component\Validator\Constraints\NoBannedWords; | ||
use Symfony\Component\Validator\Exception\ConstraintDefinitionException; | ||
|
||
class NoBannedWordsTest extends TestCase | ||
{ | ||
public function testConstructor() | ||
{ | ||
$constraint = new NoBannedWords(); | ||
$this->assertEquals([], $constraint->dictionary); | ||
} | ||
|
||
public function testConstructorWithParameters() | ||
{ | ||
$constraint = new NoBannedWords([ | ||
'dictionary' => ['foo', 'bar'], | ||
]); | ||
|
||
$this->assertEquals(['foo', 'bar'], $constraint->dictionary); | ||
} | ||
|
||
public function testInvalidDictionary() | ||
{ | ||
$this->expectException(ConstraintDefinitionException::class); | ||
$this->expectExceptionMessage('The parameter "dictionary" of the "Symfony\Component\Validator\Constraints\NoBannedWords" constraint must be a list of strings.'); | ||
new NoBannedWords(['dictionary' => [123]]); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
<?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\NoBannedWords; | ||
use Symfony\Component\Validator\Constraints\NoBannedWordsValidator; | ||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; | ||
|
||
class NoBannedWordsValidatorTest extends ConstraintValidatorTestCase | ||
{ | ||
protected function createValidator(): NoBannedWordsValidator | ||
{ | ||
return new NoBannedWordsValidator(); | ||
} | ||
|
||
/** | ||
* @dataProvider getValidValues | ||
*/ | ||
public function testValidValues(string $value) | ||
{ | ||
$this->validator->validate($value, new NoBannedWords([ | ||
'dictionary' => ['foo', 'bar'], | ||
])); | ||
|
||
$this->assertNoViolation(); | ||
} | ||
|
||
public static function getValidValues(): iterable | ||
{ | ||
yield ['This text does not contain any banned words.']; | ||
yield ['Another text that does not contain any banned words']; | ||
} | ||
|
||
/** | ||
* @dataProvider provideInvalidConstraints | ||
*/ | ||
public function testBannedWordsAreCatched(NoBannedWords $constraint, string $password, string $expectedMessage, string $expectedCode, array $parameters = []) | ||
{ | ||
$this->validator->validate($password, $constraint); | ||
|
||
$this->buildViolation($expectedMessage) | ||
->setCode($expectedCode) | ||
->setParameters($parameters) | ||
->assertRaised(); | ||
} | ||
|
||
public static function provideInvalidConstraints(): iterable | ||
{ | ||
yield [ | ||
new NoBannedWords([ | ||
'dictionary' => ['symfony'], | ||
]), | ||
'This text contains symfony, which is not allowed.', | ||
'The value contains the following banned words: {{ wordList }}.', | ||
NoBannedWords::BANNED_WORDS_ERROR, | ||
[ | ||
'{{ matches }}' => 'symfony', | ||
'{{ dictionary }}' => 'symfony', | ||
], | ||
]; | ||
yield [ | ||
new NoBannedWords([ | ||
'dictionary' => ['symfony'], | ||
]), | ||
'This text contains $yMph0NY, which is a banned words written in l337.', | ||
'The value contains the following banned words: {{ wordList }}.', | ||
NoBannedWords::BANNED_WORDS_ERROR, | ||
[ | ||
'{{ matches }}' => '$yMph0NY', | ||
'{{ dictionary }}' => 'symfony', | ||
], | ||
]; | ||
yield [ | ||
new NoBannedWords([ | ||
'dictionary' => ['symfony', 'foo', 'bar'], | ||
]), | ||
'This text contains $yMph0NY, f00 and b4r, which are all banned words written in l337.', | ||
'The value contains the following banned words: {{ wordList }}.', | ||
NoBannedWords::BANNED_WORDS_ERROR, | ||
[ | ||
'{{ matches }}' => '$yMph0NY, f00, b4r', | ||
'{{ dictionary }}' => 'symfony, foo, bar', | ||
], | ||
]; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you should keep
$options
as the first argument. Otherwise, this will break the XML and YAML loaders, which only pass a single argument with all options. Named arguments for all available options must come after it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK thanks.