Skip to content

Commit 5a69e81

Browse files
kbondnicolas-grekas
authored andcommitted
[HttpFoundation] Add UriSigner::verify() that throws named exceptions
1 parent 54169d5 commit 5a69e81

File tree

7 files changed

+210
-24
lines changed

7 files changed

+210
-24
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
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
---
Lines changed: 26 additions & 0 deletions
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+
}

Exception/SignedUriException.php

Lines changed: 19 additions & 0 deletions
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+
}

Exception/UnsignedUriException.php

Lines changed: 26 additions & 0 deletions
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+
}
Lines changed: 26 additions & 0 deletions
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+
}

Tests/UriSignerTest.php

Lines changed: 33 additions & 0 deletions
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
}

UriSigner.php

Lines changed: 79 additions & 24 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy