Skip to content

Commit ac4de2c

Browse files
kbondnicolas-grekas
authored andcommitted
[HttpFoundation] Add UriSigner::verify() that throws named exceptions
1 parent 904df78 commit ac4de2c

File tree

7 files changed

+210
-24
lines changed

7 files changed

+210
-24
lines changed

src/Symfony/Component/HttpFoundation/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ CHANGELOG
99
* Add support for `valkey:` / `valkeys:` schemes for sessions
1010
* `Request::getPreferredLanguage()` now favors a more preferred language above exactly matching a locale
1111
* Allow `UriSigner` to use a `ClockInterface`
12+
* Add `UriSigner::verify()`
1213

1314
7.2
1415
---
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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\HttpFoundation\Exception;
13+
14+
/**
15+
* @author Kevin Bond <kevinbond@gmail.com>
16+
*/
17+
final class ExpiredSignedUriException extends SignedUriException
18+
{
19+
/**
20+
* @internal
21+
*/
22+
public function __construct()
23+
{
24+
parent::__construct('The URI has expired.');
25+
}
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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\HttpFoundation\Exception;
13+
14+
/**
15+
* @author Kevin Bond <kevinbond@gmail.com>
16+
*/
17+
abstract class SignedUriException extends \RuntimeException implements ExceptionInterface
18+
{
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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\HttpFoundation\Exception;
13+
14+
/**
15+
* @author Kevin Bond <kevinbond@gmail.com>
16+
*/
17+
final class UnsignedUriException extends SignedUriException
18+
{
19+
/**
20+
* @internal
21+
*/
22+
public function __construct()
23+
{
24+
parent::__construct('The URI is not signed.');
25+
}
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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\HttpFoundation\Exception;
13+
14+
/**
15+
* @author Kevin Bond <kevinbond@gmail.com>
16+
*/
17+
final class UnverifiedSignedUriException extends SignedUriException
18+
{
19+
/**
20+
* @internal
21+
*/
22+
public function __construct()
23+
{
24+
parent::__construct('The URI signature is invalid.');
25+
}
26+
}

src/Symfony/Component/HttpFoundation/Tests/UriSignerTest.php

+33
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\Clock\MockClock;
16+
use Symfony\Component\HttpFoundation\Exception\ExpiredSignedUriException;
1617
use Symfony\Component\HttpFoundation\Exception\LogicException;
18+
use Symfony\Component\HttpFoundation\Exception\UnsignedUriException;
19+
use Symfony\Component\HttpFoundation\Exception\UnverifiedSignedUriException;
1720
use Symfony\Component\HttpFoundation\Request;
1821
use Symfony\Component\HttpFoundation\UriSigner;
1922

@@ -228,4 +231,34 @@ public function testNonUrlSafeBase64()
228231
$signer = new UriSigner('foobar');
229232
$this->assertTrue($signer->check('http://example.com/foo?_hash=rIOcC%2FF3DoEGo%2FvnESjSp7uU9zA9S%2F%2BOLhxgMexoPUM%3D&baz=bay&foo=bar'));
230233
}
234+
235+
public function testVerifyUnSignedUri()
236+
{
237+
$signer = new UriSigner('foobar');
238+
$uri = 'http://example.com/foo';
239+
240+
$this->expectException(UnsignedUriException::class);
241+
242+
$signer->verify($uri);
243+
}
244+
245+
public function testVerifyUnverifiedUri()
246+
{
247+
$signer = new UriSigner('foobar');
248+
$uri = 'http://example.com/foo?_hash=invalid';
249+
250+
$this->expectException(UnverifiedSignedUriException::class);
251+
252+
$signer->verify($uri);
253+
}
254+
255+
public function testVerifyExpiredUri()
256+
{
257+
$signer = new UriSigner('foobar');
258+
$uri = $signer->sign('http://example.com/foo', 123456);
259+
260+
$this->expectException(ExpiredSignedUriException::class);
261+
262+
$signer->verify($uri);
263+
}
231264
}

src/Symfony/Component/HttpFoundation/UriSigner.php

+79-24
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,22 @@
1212
namespace Symfony\Component\HttpFoundation;
1313

1414
use Psr\Clock\ClockInterface;
15+
use Symfony\Component\HttpFoundation\Exception\ExpiredSignedUriException;
1516
use Symfony\Component\HttpFoundation\Exception\LogicException;
17+
use Symfony\Component\HttpFoundation\Exception\SignedUriException;
18+
use Symfony\Component\HttpFoundation\Exception\UnsignedUriException;
19+
use Symfony\Component\HttpFoundation\Exception\UnverifiedSignedUriException;
1620

1721
/**
1822
* @author Fabien Potencier <fabien@symfony.com>
1923
*/
2024
class UriSigner
2125
{
26+
private const STATUS_VALID = 1;
27+
private const STATUS_INVALID = 2;
28+
private const STATUS_MISSING = 3;
29+
private const STATUS_EXPIRED = 4;
30+
2231
/**
2332
* @param string $hashParameter Query string parameter to use
2433
* @param string $expirationParameter Query string parameter to use for expiration
@@ -91,38 +100,40 @@ public function sign(string $uri/* , \DateTimeInterface|\DateInterval|int|null $
91100
*/
92101
public function check(string $uri): bool
93102
{
94-
$url = parse_url($uri);
95-
$params = [];
96-
97-
if (isset($url['query'])) {
98-
parse_str($url['query'], $params);
99-
}
103+
return self::STATUS_VALID === $this->doVerify($uri);
104+
}
100105

101-
if (empty($params[$this->hashParameter])) {
102-
return false;
103-
}
106+
public function checkRequest(Request $request): bool
107+
{
108+
return self::STATUS_VALID === $this->doVerify(self::normalize($request));
109+
}
104110

105-
$hash = $params[$this->hashParameter];
106-
unset($params[$this->hashParameter]);
111+
/**
112+
* Verify a Request or string URI.
113+
*
114+
* @throws UnsignedUriException If the URI is not signed
115+
* @throws UnverifiedSignedUriException If the signature is invalid
116+
* @throws ExpiredSignedUriException If the URI has expired
117+
* @throws SignedUriException
118+
*/
119+
public function verify(Request|string $uri): void
120+
{
121+
$uri = self::normalize($uri);
122+
$status = $this->doVerify($uri);
107123

108-
// In 8.0, remove support for non-url-safe tokens
109-
if (!hash_equals($this->computeHash($this->buildUrl($url, $params)), strtr(rtrim($hash, '='), ['/' => '_', '+' => '-']))) {
110-
return false;
124+
if (self::STATUS_VALID === $status) {
125+
return;
111126
}
112127

113-
if ($expiration = $params[$this->expirationParameter] ?? false) {
114-
return $this->now()->getTimestamp() < $expiration;
128+
if (self::STATUS_MISSING === $status) {
129+
throw new UnsignedUriException();
115130
}
116131

117-
return true;
118-
}
119-
120-
public function checkRequest(Request $request): bool
121-
{
122-
$qs = ($qs = $request->server->get('QUERY_STRING')) ? '?'.$qs : '';
132+
if (self::STATUS_INVALID === $status) {
133+
throw new UnverifiedSignedUriException();
134+
}
123135

124-
// we cannot use $request->getUri() here as we want to work with the original URI (no query string reordering)
125-
return $this->check($request->getSchemeAndHttpHost().$request->getBaseUrl().$request->getPathInfo().$qs);
136+
throw new ExpiredSignedUriException();
126137
}
127138

128139
private function computeHash(string $uri): string
@@ -165,4 +176,48 @@ private function now(): \DateTimeImmutable
165176
{
166177
return $this->clock?->now() ?? \DateTimeImmutable::createFromFormat('U', time());
167178
}
179+
180+
/**
181+
* @return self::STATUS_*
182+
*/
183+
private function doVerify(string $uri): int
184+
{
185+
$url = parse_url($uri);
186+
$params = [];
187+
188+
if (isset($url['query'])) {
189+
parse_str($url['query'], $params);
190+
}
191+
192+
if (empty($params[$this->hashParameter])) {
193+
return self::STATUS_MISSING;
194+
}
195+
196+
$hash = $params[$this->hashParameter];
197+
unset($params[$this->hashParameter]);
198+
199+
if (!hash_equals($this->computeHash($this->buildUrl($url, $params)), strtr(rtrim($hash, '='), ['/' => '_', '+' => '-']))) {
200+
return self::STATUS_INVALID;
201+
}
202+
203+
if (!$expiration = $params[$this->expirationParameter] ?? false) {
204+
return self::STATUS_VALID;
205+
}
206+
207+
if ($this->now()->getTimestamp() < $expiration) {
208+
return self::STATUS_VALID;
209+
}
210+
211+
return self::STATUS_EXPIRED;
212+
}
213+
214+
private static function normalize(Request|string $uri): string
215+
{
216+
if ($uri instanceof Request) {
217+
$qs = ($qs = $uri->server->get('QUERY_STRING')) ? '?'.$qs : '';
218+
$uri = $uri->getSchemeAndHttpHost().$uri->getBaseUrl().$uri->getPathInfo().$qs;
219+
}
220+
221+
return $uri;
222+
}
168223
}

0 commit comments

Comments
 (0)