Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Validator] Add a HaveIBeenPwned password validator #27738

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

Merged
merged 1 commit into from
Apr 1, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/Symfony/Component/Validator/Constraints/NotPwned.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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;

/**
* Checks if a password has been leaked in a data breach.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps clarify the password should NOT be leaked :/

*
* @Annotation
* @Target({"PROPERTY", "METHOD", "ANNOTATION"})
*
* @author Kévin Dunglas <[email protected]>
*/
class NotPwned extends Constraint
{
const PWNED_ERROR = 'd9bcdbfe-a9d6-4bfa-a8ff-da5fd93e0f6d';

protected static $errorNames = [self::PWNED_ERROR => 'PWNED_ERROR'];

public $message = 'This password has been leaked in a data breach, it must not be used. Please use another password.';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be added in validators.en.xlf + any language you know :)

not sure we'll do another round of good first issues for the remaining locales :}

public $threshold = 1;
public $skipOnError = false;
}
90 changes: 90 additions & 0 deletions src/Symfony/Component/Validator/Constraints/NotPwnedValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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\HttpClient\HttpClient;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* Checks if a password has been leaked in a data breach using haveibeenpwned.com's API.
* Use a k-anonymity model to protect the password being searched for.
*
* @see https://haveibeenpwned.com/API/v2#SearchingPwnedPasswordsByRange
*
* @author Kévin Dunglas <[email protected]>
*/
class NotPwnedValidator extends ConstraintValidator
{
private const RANGE_API = 'https://api.pwnedpasswords.com/range/%s';

private $httpClient;

public function __construct(HttpClientInterface $httpClient = null)
{
if (null === $httpClient && !class_exists(HttpClient::class)) {
throw new \LogicException(sprintf('The "%s" class requires the "HttpClient" component. Try running "composer require symfony/http-client".', self::class));
}

$this->httpClient = $httpClient ?? HttpClient::create();
}

/**
* {@inheritdoc}
*
* @throws ExceptionInterface
*/
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof NotPwned) {
throw new UnexpectedTypeException($constraint, NotPwned::class);
}

if (null !== $value && !is_scalar($value) && !(\is_object($value) && method_exists($value, '__toString'))) {
throw new UnexpectedTypeException($value, 'string');
}

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

$hash = strtoupper(sha1($value));
$hashPrefix = substr($hash, 0, 5);
$url = sprintf(self::RANGE_API, $hashPrefix);

try {
$result = $this->httpClient->request('GET', $url)->getContent();
} catch (ExceptionInterface $e) {
if ($constraint->skipOnError) {
return;
}

throw $e;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't think that throwing here by default makes sense. What should be configurable is whether a HTTP failure will make the validator create a constraint violation or just skip.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't think that throwing here by default makes sense.

The system must be as secure as possible by default. If there is an outage for the service, I prefer to retry latter to create the account of my company user than letting using something like "mum", or one that already has leaked. Now this behavior can be change using a simple attribute.

What should be configurable is whether a HTTP failure will make the validator create a constraint violation or just skip.

It's exactly what the new attribute does, or am I missing something?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwing an exception will not create a constraint violation, but will lead in a server error. From the user's point of view that's the worst that could happen as they won't get any feedback of what went wrong and if there is anything they could do.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But if de don’t throw, how the monitoring system will detect the ongoing issue? It should be very exceptional and should probably trigger an alert.

Alternatively I can change the attribute to accept three value: throw (default), skip or fail. wdyt?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should log by default and add a scream option (defaulting to false) to allow to opt-in.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is an exception it basically means that 3rd party is down, which means that there is an unrecoverable error. If you (as a system) decided that whatever password entered should NOT have been "pwned" then at this point we should throw an exception here, not log something. Now, if you "prefer" it to not have been "pwned" just set skipOnError to true and you're covered.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm on the same side as @sroze

}

foreach (explode("\r\n", $result) as $line) {
list($hashSuffix, $count) = explode(':', $line);

if ($hashPrefix.$hashSuffix === $hash && $constraint->threshold <= (int) $count) {
$this->context->buildViolation($constraint->message)
->setCode(NotPwned::PWNED_ERROR)
->addViolation();

return;
}
}
}
}
28 changes: 28 additions & 0 deletions src/Symfony/Component/Validator/Tests/Constraints/NotPwnedTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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\NotPwned;

/**
* @author Kévin Dunglas <[email protected]>
*/
class NotPwnedTest extends TestCase
{
public function testDefaultValues()
{
$constraint = new NotPwned();
$this->assertSame(1, $constraint->threshold);
$this->assertFalse($constraint->skipOnError);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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\Luhn;
use Symfony\Component\Validator\Constraints\NotPwned;
use Symfony\Component\Validator\Constraints\NotPwnedValidator;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

/**
* @author Kévin Dunglas <[email protected]>
*/
class NotPwnedValidatorTest extends ConstraintValidatorTestCase
{
private const PASSWORD_TRIGGERING_AN_ERROR = 'apiError';
private const PASSWORD_TRIGGERING_AN_ERROR_RANGE_URL = 'https://api.pwnedpasswords.com/range/3EF27'; // https://api.pwnedpasswords.com/range/3EF27 is the range for the value "apiError"
private const PASSWORD_LEAKED = 'maman';
private const PASSWORD_NOT_LEAKED = ']<0585"%sb^5aa$w6!b38",,72?dp3r4\45b28Hy';

private const RETURN = [
'35E033023A46402F94CFB4F654C5BFE44A1:1',
'35F079CECCC31812288257CD770AA7968D7:53',
'36039744C253F9B2A4E90CBEDB02EBFB82D:5', // this is the matching line, password: maman
'3686792BBC66A72D40D928ED15621124CFE:7',
'36EEC709091B810AA240179A44317ED415C:2',
];

protected function createValidator()
{
$httpClientStub = $this->createMock(HttpClientInterface::class);
$httpClientStub->method('request')->will(
$this->returnCallback(function (string $method, string $url): ResponseInterface {
if (self::PASSWORD_TRIGGERING_AN_ERROR_RANGE_URL === $url) {
throw new class('Problem contacting the Have I been Pwned API.') extends \Exception implements ServerExceptionInterface {
public function getResponse(): ResponseInterface
{
throw new \RuntimeException('Not implemented');
}
};
}

$responseStub = $this->createMock(ResponseInterface::class);
$responseStub
->method('getContent')
->willReturn(implode("\r\n", self::RETURN));

return $responseStub;
})
);

// Pass HttpClient::create() instead of this mock to run the tests against the real API
return new NotPwnedValidator($httpClientStub);
}

public function testNullIsValid()
{
$this->validator->validate(null, new NotPwned());

$this->assertNoViolation();
}

public function testEmptyStringIsValid()
{
$this->validator->validate('', new NotPwned());

$this->assertNoViolation();
}

public function testInvalidPassword()
{
$constraint = new NotPwned();
$this->validator->validate(self::PASSWORD_LEAKED, $constraint);

$this->buildViolation($constraint->message)
->setCode(NotPwned::PWNED_ERROR)
->assertRaised();
}

public function testThresholdReached()
{
$constraint = new NotPwned(['threshold' => 3]);
$this->validator->validate(self::PASSWORD_LEAKED, $constraint);

$this->buildViolation($constraint->message)
->setCode(NotPwned::PWNED_ERROR)
->assertRaised();
}

public function testThresholdNotReached()
{
$this->validator->validate(self::PASSWORD_LEAKED, new NotPwned(['threshold' => 10]));

$this->assertNoViolation();
}

public function testValidPassword()
{
$this->validator->validate(self::PASSWORD_NOT_LEAKED, new NotPwned());

$this->assertNoViolation();
}

/**
* @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException
*/
public function testInvalidConstraint()
{
$this->validator->validate(null, new Luhn());
}

/**
* @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException
*/
public function testInvalidValue()
{
$this->validator->validate([], new NotPwned());
}

/**
* @expectedException \Symfony\Contracts\HttpClient\Exception\ExceptionInterface
* @expectedExceptionMessage Problem contacting the Have I been Pwned API.
*/
public function testApiError()
{
$this->validator->validate(self::PASSWORD_TRIGGERING_AN_ERROR, new NotPwned());
}

public function testApiErrorSkipped()
{
$this->validator->validate(self::PASSWORD_TRIGGERING_AN_ERROR, new NotPwned(['skipOnError' => true]));
$this->assertTrue(true); // No exception have been thrown
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/Validator/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"symfony/polyfill-mbstring": "~1.0"
},
"require-dev": {
"symfony/http-client": "^4.3",
"symfony/http-foundation": "~4.1",
"symfony/http-kernel": "~3.4|~4.0",
"symfony/var-dumper": "~3.4|~4.0",
Expand Down