From 4614ba232918077b97f2584d004621ef82e7ce86 Mon Sep 17 00:00:00 2001 From: Baptiste CONTRERAS <38988658+BaptisteContreras@users.noreply.github.com> Date: Sun, 27 Aug 2023 15:11:16 +0200 Subject: [PATCH] [HttpFoundation] Add temporary URI signed --- .../Component/HttpFoundation/CHANGELOG.md | 2 + .../HttpFoundation/Tests/UriSignerTest.php | 90 ++++++++++++++++++- .../Component/HttpFoundation/UriSigner.php | 56 ++++++++++-- 3 files changed, 142 insertions(+), 6 deletions(-) diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index 61297e2c148b1..15c2c58158b70 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -9,6 +9,8 @@ CHANGELOG * Add `UriSigner` from the HttpKernel component * Add `partitioned` flag to `Cookie` (CHIPS Cookie) * Add argument `bool $flush = true` to `Response::send()` + * Add optional `$expirationParameter` argument to `UriSigner::__construct()` + * Add optional `$expiration` argument to `UriSigner::sign()` 6.3 --- diff --git a/src/Symfony/Component/HttpFoundation/Tests/UriSignerTest.php b/src/Symfony/Component/HttpFoundation/Tests/UriSignerTest.php index dfbe81e8827f9..c4830e09c8a77 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/UriSignerTest.php +++ b/src/Symfony/Component/HttpFoundation/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 \DateTimeImmutable('2099-01-01 00:00:00')))); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar', new \DateTimeImmutable('2099-01-01 00:00:00')))); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&0=integer', new \DateTimeImmutable('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 \DateTimeImmutable('2099-01-01 00:00:00')) + ); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTimeImmutable('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 \DateTimeImmutable('2099-01-01 00:00:00'))))); + $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo?foo=bar', new \DateTimeImmutable('2099-01-01 00:00:00'))))); + $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo?foo=bar&0=integer', new \DateTimeImmutable('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 \DateTimeImmutable('2099-01-01 00:00:00')) + ); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTimeImmutable('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 \DateTimeImmutable('2099-01-01 00:00:00')) + ); + + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar', new \DateTimeImmutable('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 \DateTimeImmutable('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 \DateTimeImmutable('2099-01-01 00:00:00')); + } + + public function testCheckWithUriExpiration() + { + $signer = new UriSigner('foobar'); + + $this->assertFalse($signer->check($signer->sign('http://example.com/foo', new \DateTimeImmutable('2000-01-01 00:00:00')))); + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar', new \DateTimeImmutable('2000-01-01 00:00:00')))); + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar&0=integer', new \DateTimeImmutable('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/HttpFoundation/UriSigner.php b/src/Symfony/Component/HttpFoundation/UriSigner.php index 091ac03e479d4..3afc37823bc0d 100644 --- a/src/Symfony/Component/HttpFoundation/UriSigner.php +++ b/src/Symfony/Component/HttpFoundation/UriSigner.php @@ -20,15 +20,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; } /** @@ -36,8 +39,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 = []; @@ -46,6 +57,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); @@ -54,6 +73,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 { @@ -71,7 +91,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 @@ -104,6 +132,24 @@ 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 time() >= $expiration; + } } if (!class_exists(\Symfony\Component\HttpKernel\UriSigner::class, false)) {