diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index 5410cba632897..374c31889df3c 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG * Add support for `valkey:` / `valkeys:` schemes for sessions * `Request::getPreferredLanguage()` now favors a more preferred language above exactly matching a locale * Allow `UriSigner` to use a `ClockInterface` + * Add `UriSigner::verify()` 7.2 --- diff --git a/src/Symfony/Component/HttpFoundation/Exception/ExpiredSignedUriException.php b/src/Symfony/Component/HttpFoundation/Exception/ExpiredSignedUriException.php new file mode 100644 index 0000000000000..613e08ef46c63 --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Exception/ExpiredSignedUriException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Exception; + +/** + * @author Kevin Bond + */ +final class ExpiredSignedUriException extends SignedUriException +{ + /** + * @internal + */ + public function __construct() + { + parent::__construct('The URI has expired.'); + } +} diff --git a/src/Symfony/Component/HttpFoundation/Exception/SignedUriException.php b/src/Symfony/Component/HttpFoundation/Exception/SignedUriException.php new file mode 100644 index 0000000000000..17b729d315d70 --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Exception/SignedUriException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Exception; + +/** + * @author Kevin Bond + */ +abstract class SignedUriException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/HttpFoundation/Exception/UnsignedUriException.php b/src/Symfony/Component/HttpFoundation/Exception/UnsignedUriException.php new file mode 100644 index 0000000000000..5eabb806b2370 --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Exception/UnsignedUriException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Exception; + +/** + * @author Kevin Bond + */ +final class UnsignedUriException extends SignedUriException +{ + /** + * @internal + */ + public function __construct() + { + parent::__construct('The URI is not signed.'); + } +} diff --git a/src/Symfony/Component/HttpFoundation/Exception/UnverifiedSignedUriException.php b/src/Symfony/Component/HttpFoundation/Exception/UnverifiedSignedUriException.php new file mode 100644 index 0000000000000..cc7e98bf2dd3c --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Exception/UnverifiedSignedUriException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Exception; + +/** + * @author Kevin Bond + */ +final class UnverifiedSignedUriException extends SignedUriException +{ + /** + * @internal + */ + public function __construct() + { + parent::__construct('The URI signature is invalid.'); + } +} diff --git a/src/Symfony/Component/HttpFoundation/Tests/UriSignerTest.php b/src/Symfony/Component/HttpFoundation/Tests/UriSignerTest.php index 85a0b727ccda3..81b35c28e1fc9 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/UriSignerTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/UriSignerTest.php @@ -13,7 +13,10 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Clock\MockClock; +use Symfony\Component\HttpFoundation\Exception\ExpiredSignedUriException; use Symfony\Component\HttpFoundation\Exception\LogicException; +use Symfony\Component\HttpFoundation\Exception\UnsignedUriException; +use Symfony\Component\HttpFoundation\Exception\UnverifiedSignedUriException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\UriSigner; @@ -228,4 +231,34 @@ public function testNonUrlSafeBase64() $signer = new UriSigner('foobar'); $this->assertTrue($signer->check('http://example.com/foo?_hash=rIOcC%2FF3DoEGo%2FvnESjSp7uU9zA9S%2F%2BOLhxgMexoPUM%3D&baz=bay&foo=bar')); } + + public function testVerifyUnSignedUri() + { + $signer = new UriSigner('foobar'); + $uri = 'http://example.com/foo'; + + $this->expectException(UnsignedUriException::class); + + $signer->verify($uri); + } + + public function testVerifyUnverifiedUri() + { + $signer = new UriSigner('foobar'); + $uri = 'http://example.com/foo?_hash=invalid'; + + $this->expectException(UnverifiedSignedUriException::class); + + $signer->verify($uri); + } + + public function testVerifyExpiredUri() + { + $signer = new UriSigner('foobar'); + $uri = $signer->sign('http://example.com/foo', 123456); + + $this->expectException(ExpiredSignedUriException::class); + + $signer->verify($uri); + } } diff --git a/src/Symfony/Component/HttpFoundation/UriSigner.php b/src/Symfony/Component/HttpFoundation/UriSigner.php index b1109ae692326..bb870e43c56f3 100644 --- a/src/Symfony/Component/HttpFoundation/UriSigner.php +++ b/src/Symfony/Component/HttpFoundation/UriSigner.php @@ -12,13 +12,22 @@ namespace Symfony\Component\HttpFoundation; use Psr\Clock\ClockInterface; +use Symfony\Component\HttpFoundation\Exception\ExpiredSignedUriException; use Symfony\Component\HttpFoundation\Exception\LogicException; +use Symfony\Component\HttpFoundation\Exception\SignedUriException; +use Symfony\Component\HttpFoundation\Exception\UnsignedUriException; +use Symfony\Component\HttpFoundation\Exception\UnverifiedSignedUriException; /** * @author Fabien Potencier */ class UriSigner { + private const STATUS_VALID = 1; + private const STATUS_INVALID = 2; + private const STATUS_MISSING = 3; + private const STATUS_EXPIRED = 4; + /** * @param string $hashParameter Query string parameter to use * @param string $expirationParameter Query string parameter to use for expiration @@ -91,38 +100,40 @@ public function sign(string $uri/* , \DateTimeInterface|\DateInterval|int|null $ */ public function check(string $uri): bool { - $url = parse_url($uri); - $params = []; - - if (isset($url['query'])) { - parse_str($url['query'], $params); - } + return self::STATUS_VALID === $this->doVerify($uri); + } - if (empty($params[$this->hashParameter])) { - return false; - } + public function checkRequest(Request $request): bool + { + return self::STATUS_VALID === $this->doVerify(self::normalize($request)); + } - $hash = $params[$this->hashParameter]; - unset($params[$this->hashParameter]); + /** + * Verify a Request or string URI. + * + * @throws UnsignedUriException If the URI is not signed + * @throws UnverifiedSignedUriException If the signature is invalid + * @throws ExpiredSignedUriException If the URI has expired + * @throws SignedUriException + */ + public function verify(Request|string $uri): void + { + $uri = self::normalize($uri); + $status = $this->doVerify($uri); - // In 8.0, remove support for non-url-safe tokens - if (!hash_equals($this->computeHash($this->buildUrl($url, $params)), strtr(rtrim($hash, '='), ['/' => '_', '+' => '-']))) { - return false; + if (self::STATUS_VALID === $status) { + return; } - if ($expiration = $params[$this->expirationParameter] ?? false) { - return $this->now()->getTimestamp() < $expiration; + if (self::STATUS_MISSING === $status) { + throw new UnsignedUriException(); } - return true; - } - - public function checkRequest(Request $request): bool - { - $qs = ($qs = $request->server->get('QUERY_STRING')) ? '?'.$qs : ''; + if (self::STATUS_INVALID === $status) { + throw new UnverifiedSignedUriException(); + } - // we cannot use $request->getUri() here as we want to work with the original URI (no query string reordering) - return $this->check($request->getSchemeAndHttpHost().$request->getBaseUrl().$request->getPathInfo().$qs); + throw new ExpiredSignedUriException(); } private function computeHash(string $uri): string @@ -165,4 +176,48 @@ private function now(): \DateTimeImmutable { return $this->clock?->now() ?? \DateTimeImmutable::createFromFormat('U', time()); } + + /** + * @return self::STATUS_* + */ + private function doVerify(string $uri): int + { + $url = parse_url($uri); + $params = []; + + if (isset($url['query'])) { + parse_str($url['query'], $params); + } + + if (empty($params[$this->hashParameter])) { + return self::STATUS_MISSING; + } + + $hash = $params[$this->hashParameter]; + unset($params[$this->hashParameter]); + + if (!hash_equals($this->computeHash($this->buildUrl($url, $params)), strtr(rtrim($hash, '='), ['/' => '_', '+' => '-']))) { + return self::STATUS_INVALID; + } + + if (!$expiration = $params[$this->expirationParameter] ?? false) { + return self::STATUS_VALID; + } + + if ($this->now()->getTimestamp() < $expiration) { + return self::STATUS_VALID; + } + + return self::STATUS_EXPIRED; + } + + private static function normalize(Request|string $uri): string + { + if ($uri instanceof Request) { + $qs = ($qs = $uri->server->get('QUERY_STRING')) ? '?'.$qs : ''; + $uri = $uri->getSchemeAndHttpHost().$uri->getBaseUrl().$uri->getPathInfo().$qs; + } + + return $uri; + } }