Skip to content

Commit

Permalink
[HttpFoundation] Add temporary URI signed
Browse files Browse the repository at this point in the history
  • Loading branch information
BaptisteContreras committed Oct 30, 2023
1 parent 89fdb22 commit 910ee02
Show file tree
Hide file tree
Showing 4 changed files with 301 additions and 6 deletions.
2 changes: 2 additions & 0 deletions src/Symfony/Component/HttpFoundation/CHANGELOG.md
Expand Up @@ -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
---
Expand Down
90 changes: 89 additions & 1 deletion src/Symfony/Component/HttpFoundation/Tests/UriSignerTest.php
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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));
}
}
56 changes: 51 additions & 5 deletions src/Symfony/Component/HttpFoundation/UriSigner.php
Expand Up @@ -20,24 +20,35 @@ 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;
}

/**
* Signs a URI.
*
* 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 = [];
Expand All @@ -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);

Expand All @@ -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
{
Expand All @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down

0 comments on commit 910ee02

Please sign in to comment.