diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index e5d58e8cb3837..ad044a04baf2a 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -11,6 +11,8 @@ CHANGELOG * Add argument `$validationFailedStatusCode` to `#[MapQueryString]` and `#[MapRequestPayload]` * Add argument `$debug` to `Logger` * Add class `DebugLoggerConfigurator` + * Add optional `$expirationParameter` argument to `UriSigner::__construct()` + * Add optional `$expiration` argument to `UriSigner::sign()` 6.3 --- diff --git a/src/Symfony/Component/HttpKernel/Tests/UriSignerTest.php b/src/Symfony/Component/HttpKernel/Tests/UriSignerTest.php index 4801776cce146..79e4e37b2126e 100644 --- a/src/Symfony/Component/HttpKernel/Tests/UriSignerTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/UriSignerTest.php @@ -24,21 +24,38 @@ public function testSign() $this->assertStringContainsString('?_hash=', $signer->sign('http://example.com/foo')); $this->assertStringContainsString('?_hash=', $signer->sign('http://example.com/foo?foo=bar')); $this->assertStringContainsString('&foo=', $signer->sign('http://example.com/foo?foo=bar')); + + $this->assertStringContainsString('?_expiration=', $signer->sign('http://example.com/foo', 1)); + $this->assertStringContainsString('&_hash=', $signer->sign('http://example.com/foo', 1)); + $this->assertStringContainsString('?_expiration=', $signer->sign('http://example.com/foo?foo=bar', 1)); + $this->assertStringContainsString('&_hash=', $signer->sign('http://example.com/foo?foo=bar', 1)); + $this->assertStringContainsString('&foo=', $signer->sign('http://example.com/foo?foo=bar', 1)); } public function testCheck() { $signer = new UriSigner('foobar'); + $this->assertFalse($signer->check('http://example.com/foo')); $this->assertFalse($signer->check('http://example.com/foo?_hash=foo')); $this->assertFalse($signer->check('http://example.com/foo?foo=bar&_hash=foo')); $this->assertFalse($signer->check('http://example.com/foo?foo=bar&_hash=foo&bar=foo')); + $this->assertFalse($signer->check('http://example.com/foo?_expiration=4070908800')); + $this->assertFalse($signer->check('http://example.com/foo?_expiration=4070908800?_hash=foo')); + $this->assertFalse($signer->check('http://example.com/foo?_expiration=4070908800&foo=bar&_hash=foo')); + $this->assertFalse($signer->check('http://example.com/foo?_expiration=4070908800&foo=bar&_hash=foo&bar=foo')); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo'))); $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar'))); $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&0=integer'))); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo', new \DateTime('2099-01-01 00:00:00')))); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar', new \DateTime('2099-01-01 00:00:00')))); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&0=integer', new \DateTime('2099-01-01 00:00:00')))); + $this->assertSame($signer->sign('http://example.com/foo?foo=bar&bar=foo'), $signer->sign('http://example.com/foo?bar=foo&foo=bar')); + $this->assertSame($signer->sign('http://example.com/foo?foo=bar&bar=foo', 1), $signer->sign('http://example.com/foo?bar=foo&foo=bar', 1)); } public function testCheckWithDifferentArgSeparator() @@ -51,6 +68,12 @@ public function testCheckWithDifferentArgSeparator() $signer->sign('http://example.com/foo?foo=bar&baz=bay') ); $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay'))); + + $this->assertSame( + 'http://example.com/foo?_expiration=4070908800&_hash=xfui5FoP0vbD9Cp7pI0tHnqR1Fmj2UARqkIUw7SZVfQ%3D&baz=bay&foo=bar', + $signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTime('2099-01-01 00:00:00')) + ); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTime('2099-01-01 00:00:00')))); } public function testCheckWithRequest() @@ -60,17 +83,27 @@ public function testCheckWithRequest() $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo')))); $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo?foo=bar')))); $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo?foo=bar&0=integer')))); + + $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo', new \DateTime('2099-01-01 00:00:00'))))); + $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo?foo=bar', new \DateTime('2099-01-01 00:00:00'))))); + $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo?foo=bar&0=integer', new \DateTime('2099-01-01 00:00:00'))))); } public function testCheckWithDifferentParameter() { - $signer = new UriSigner('foobar', 'qux'); + $signer = new UriSigner('foobar', 'qux', 'abc'); $this->assertSame( 'http://example.com/foo?baz=bay&foo=bar&qux=rIOcC%2FF3DoEGo%2FvnESjSp7uU9zA9S%2F%2BOLhxgMexoPUM%3D', $signer->sign('http://example.com/foo?foo=bar&baz=bay') ); $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay'))); + + $this->assertSame( + 'http://example.com/foo?abc=4070908800&baz=bay&foo=bar&qux=hdhUhBVPpzKJdz5ZjC%2FkLvtOYdGKOvKVOczmmMIZK0A%3D', + $signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTime('2099-01-01 00:00:00')) + ); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTime('2099-01-01 00:00:00')))); } public function testSignerWorksWithFragments() @@ -81,6 +114,61 @@ public function testSignerWorksWithFragments() 'http://example.com/foo?_hash=EhpAUyEobiM3QTrKxoLOtQq5IsWyWedoXDPqIjzNj5o%3D&bar=foo&foo=bar#foobar', $signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar') ); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar'))); + + $this->assertSame( + 'http://example.com/foo?_expiration=4070908800&_hash=qHl626U5d7LMsVtBxPt9GNzysdSxyOQ1fHA59Y1ib0Y%3D&bar=foo&foo=bar#foobar', + $signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar', new \DateTime('2099-01-01 00:00:00')) + ); + + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar', new \DateTime('2099-01-01 00:00:00')))); + } + + public function testSignWithUriExpiration() + { + $signer = new UriSigner('foobar'); + + $this->assertSame($signer->sign('http://example.com/foo?foo=bar&bar=foo', new \DateTime('2099-01-01 00:00:00')), $signer->sign('http://example.com/foo?bar=foo&foo=bar', 4070908800)); + } + + public function testSignWithoutExpirationAndWithReservedParameter() + { + $signer = new UriSigner('foobar'); + + $this->expectException(\LogicException::class); + + $signer->sign('http://example.com/foo?_expiration=4070908800'); + } + + public function testSignWithExpirationAndWithReservedParameter() + { + $signer = new UriSigner('foobar'); + + $this->expectException(\LogicException::class); + + $signer->sign('http://example.com/foo?_expiration=4070908800', new \DateTime('2099-01-01 00:00:00')); + } + + public function testCheckWithUriExpiration() + { + $signer = new UriSigner('foobar'); + + $this->assertFalse($signer->check($signer->sign('http://example.com/foo', new \DateTime('2000-01-01 00:00:00')))); + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar', new \DateTime('2000-01-01 00:00:00')))); + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar&0=integer', new \DateTime('2000-01-01 00:00:00')))); + + $this->assertFalse($signer->check($signer->sign('http://example.com/foo', 1577836800))); // 2000-01-01 + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar', 1577836800))); // 2000-01-01 + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar&0=integer', 1577836800))); // 2000-01-01 + + $relativeUriFromNow1 = $signer->sign('http://example.com/foo', new \DateInterval('PT3S')); + $relativeUriFromNow2 = $signer->sign('http://example.com/foo?foo=bar', new \DateInterval('PT3S')); + $relativeUriFromNow3 = $signer->sign('http://example.com/foo?foo=bar&0=integer', new \DateInterval('PT3S')); + sleep(10); + + $this->assertFalse($signer->check($relativeUriFromNow1)); + $this->assertFalse($signer->check($relativeUriFromNow2)); + $this->assertFalse($signer->check($relativeUriFromNow3)); } } diff --git a/src/Symfony/Component/HttpKernel/UriSigner.php b/src/Symfony/Component/HttpKernel/UriSigner.php index dfc0a7d00bb84..d1fe7fa4e911d 100644 --- a/src/Symfony/Component/HttpKernel/UriSigner.php +++ b/src/Symfony/Component/HttpKernel/UriSigner.php @@ -22,15 +22,18 @@ class UriSigner { private string $secret; private string $parameter; + private string $expirationParameter; /** - * @param string $secret A secret - * @param string $parameter Query string parameter to use + * @param string $secret A secret + * @param string $parameter Query string parameter to use + * @param string $expirationParameter Query string parameter to use for expiration */ - public function __construct(#[\SensitiveParameter] string $secret, string $parameter = '_hash') + public function __construct(#[\SensitiveParameter] string $secret, string $parameter = '_hash', string $expirationParameter = '_expiration') { $this->secret = $secret; $this->parameter = $parameter; + $this->expirationParameter = $expirationParameter; } /** @@ -38,8 +41,16 @@ public function __construct(#[\SensitiveParameter] string $secret, string $param * * The given URI is signed by adding the query string parameter * which value depends on the URI and the secret. + * + * @param \DateTimeInterface|\DateInterval|int|null $expiration The expiration for the given URI. + * If $expiration is a \DateTimeInterface, it's expected to be the exact date + time. + * If $expiration is a \DateInterval, the interval is added to "now" to get the date + time. + * If $expiration is an int, it's expected to be a timestamp in seconds of the exact date + time. + * If $expiration is null, no expiration. + * + * The expiration is added as a query string parameter. */ - public function sign(string $uri): string + public function sign(string $uri, \DateTimeInterface|\DateInterval|int $expiration = null): string { $url = parse_url($uri); $params = []; @@ -48,6 +59,14 @@ public function sign(string $uri): string parse_str($url['query'], $params); } + if (isset($params[$this->expirationParameter])) { + throw new \LogicException(sprintf('URI query parameter conflict. Parameter name "%s" is reserved.', $this->expirationParameter)); + } + + if (null !== $expiration) { + $params[$this->expirationParameter] = $this->getExpirationTime($expiration); + } + $uri = $this->buildUrl($url, $params); $params[$this->parameter] = $this->computeHash($uri); @@ -56,6 +75,7 @@ public function sign(string $uri): string /** * Checks that a URI contains the correct hash. + * Also checks if the URI has not expired (If you used expiration during signing). */ public function check(string $uri): bool { @@ -73,7 +93,15 @@ public function check(string $uri): bool $hash = $params[$this->parameter]; unset($params[$this->parameter]); - return hash_equals($this->computeHash($this->buildUrl($url, $params)), $hash); + if (!hash_equals($this->computeHash($this->buildUrl($url, $params)), $hash)) { + return false; + } + + if ($expiration = $params[$this->expirationParameter] ?? false) { + return !$this->isExpired((int) $expiration); + } + + return true; } public function checkRequest(Request $request): bool @@ -106,4 +134,22 @@ private function buildUrl(array $url, array $params = []): string return $scheme.$user.$pass.$host.$port.$path.$query.$fragment; } + + private function getExpirationTime(\DateTimeInterface|\DateInterval|int $expiration): string + { + if ($expiration instanceof \DateTimeInterface) { + return $expiration->format('U'); + } + + if ($expiration instanceof \DateInterval) { + return (new \DateTimeImmutable())->add($expiration)->format('U'); + } + + return (string) $expiration; + } + + private function isExpired(int $expiration): bool + { + return ((int) microtime(true)) >= $expiration; + } }