Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/http/src/Cookie/CookieConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Tempest\Http\Cookie;

final class CookieConfig
{
public function __construct(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add docblocks to describe the functionality of each option?

/**
* Whether to discard cookies that cannot be decrypted.
* What this means: any cookies not encrypted by your application (or not whitelisted) that
* arrive with a request, will prompt tempest to request the browser to forget these cookies.
* Cookies sent unencrypted and not whitelisted will also not be available in the request object.
*/
public bool $discardUnencryptedCookies = true,

/**
* List of cookies that will not be decrypted by tempest, be available in the request object.
* Outgoing whitelisted cookies will be sent to the browser in plaintext.
*/
public array $plaintextCookies = [],
) {}
}
14 changes: 11 additions & 3 deletions packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Psr\Http\Message\UploadedFileInterface;
use Tempest\Cryptography\Encryption\Encrypter;
use Tempest\Http\Cookie\Cookie;
use Tempest\Http\Cookie\CookieConfig;
use Tempest\Http\Cookie\CookieManager;
use Tempest\Http\GenericRequest;
use Tempest\Http\Method;
Expand All @@ -25,6 +26,7 @@
public function __construct(
private Encrypter $encrypter,
private CookieManager $cookies,
private CookieConfig $cookieConfig,
) {}

public function canMap(mixed $from, mixed $to): bool
Expand Down Expand Up @@ -65,14 +67,20 @@ public function map(mixed $from, mixed $to): GenericRequest
'files' => $uploads,
'cookies' => Arr\filter(Arr\map(
array: $_COOKIE,
map: function (string $value, string $key) {
map: function (string $rawValue, string $key) {
try {
$value = \in_array($key, $this->cookieConfig->plaintextCookies, true)
? $rawValue
: $this->encrypter->decrypt($rawValue);

return new Cookie(
key: $key,
value: $this->encrypter->decrypt($value),
value: $value,
);
} catch (Throwable) {
$this->cookies->remove($key);
if ($this->cookieConfig->discardUnencryptedCookies) {
$this->cookies->remove($key);
}

return null;
}
Expand Down
10 changes: 7 additions & 3 deletions packages/router/src/SetCookieHeadersMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Tempest\Router;

use Tempest\Cryptography\Encryption\Encrypter;
use Tempest\Http\Cookie\CookieConfig;
use Tempest\Http\Cookie\CookieManager;
use Tempest\Http\Request;
use Tempest\Http\Response;
Expand All @@ -19,16 +20,19 @@
public function __construct(
private Encrypter $encrypter,
private CookieManager $cookies,
private CookieConfig $cookieConfig,
) {}

public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
{
$response = $next($request);

foreach ($this->cookies->all() as $cookie) {
$cookieValue = $cookie->value === ''
? ''
: $this->encrypter->encrypt($cookie->value)->serialize();
$cookieValue = match (true) {
$cookie->value === '' => '',
\in_array($cookie->key, $this->cookieConfig->plaintextCookies, true) => $cookie->value,
default => $this->encrypter->encrypt($cookie->value)->serialize(),
};

$response->addHeader('set-cookie', (string) $cookie->withValue($cookieValue));
}
Expand Down
198 changes: 198 additions & 0 deletions tests/Integration/Http/CookieHandlingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<?php

declare(strict_types=1);

namespace Integration\Http;

use PHPUnit\Framework\Attributes\Test;
use ReflectionClass;
use Tempest\Cryptography\Encryption\Encrypter;
use Tempest\Http\Cookie\Cookie;
use Tempest\Http\Cookie\CookieConfig;
use Tempest\Http\Request;
use Tempest\Http\Responses\Ok;
use Tempest\Reflection\MethodReflector;
use Tempest\Router\Get;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;

final class CookieHandlingTest extends FrameworkIntegrationTestCase
{
#[Test]
public function encrypted_cookies_are_kept_when_default(): void
{
try {
$encrypter = $this->container->get(Encrypter::class);
$_COOKIE['Cookie_name'] = $encrypter->encrypt('myCookieValue')->serialize();

$responseHelper = $this->http
->registerRoute($this->returnCookieValueController())
->get('/get_cookie_value')
->assertOk()
->assertSee('myCookieValue');

foreach ($responseHelper->headers as $header) {
if ($header->name !== 'set-cookie') {
continue;
}

foreach ($header->values as $value) {
$this->assertNotEquals(
$value,
'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax',
);
}
}
} finally {
unset($_COOKIE['Cookie_name']);
}
}

#[Test]
public function unencrypted_cookies_are_discarded_when_default(): void
{
try {
$_COOKIE['Cookie_name'] = 'myCookieValue';

$this->http
->registerRoute($this->returnCookieValueController())
->get('/get_cookie_value')
->assertOk()
->assertHeaderMatches('set-cookie', 'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax')
->assertNotSee('myCookieValue');
} finally {
unset($_COOKIE['Cookie_name']);
}
}

#[Test]
public function unencrypted_cookies_are_kept_when_discard_false(): void
{
$this->container->config(new CookieConfig(discardUnencryptedCookies: false));

try {
$_COOKIE['Cookie_name'] = 'myCookieValue';

$responseHelper = $this->http
->registerRoute($this->returnCookieValueController())
->get('/get_cookie_value')
->assertOk()
->assertNotSee('myCookieValue'); // cookies are not discarded but not whitelisted so not available

foreach ($responseHelper->headers as $header) {
if ($header->name !== 'set-cookie') {
continue;
}

foreach ($header->values as $value) {
$this->assertNotEquals(
$value,
'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax',
);
}
}
} finally {
unset($_COOKIE['Cookie_name']);
}
}

#[Test]
public function unencrypted_cookies_are_discarded_when_discard_true(): void
{
$this->container->config(new CookieConfig(discardUnencryptedCookies: true));

try {
$_COOKIE['Cookie_name'] = 'myCookieValue';

$this->http
->registerRoute($this->returnCookieValueController())
->get('/get_cookie_value')
->assertOk()
->assertHeaderMatches('set-cookie', 'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax')
->assertNotSee('myCookieValue');
} finally {
unset($_COOKIE['Cookie_name']);
}
}

#[Test]
public function whitelisted_plaintext_cookies_are_kept(): void
{
$this->container->config(new CookieConfig(
discardUnencryptedCookies: true,
plaintextCookies: ['Cookie_name'],
));

try {
$_COOKIE['Cookie_name'] = 'myCookieValue';

$responseHelper = $this->http
->registerRoute($this->returnCookieValueController())
->get('/get_cookie_value')
->assertOk()
->assertSee('myCookieValue');

foreach ($responseHelper->headers as $header) {
if ($header->name !== 'set-cookie') {
continue;
}

foreach ($header->values as $value) {
$this->assertNotEquals(
$value,
'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax',
);
}
}
} finally {
unset($_COOKIE['Cookie_name']);
}
}

#[Test]
public function whitelisted_plaintext_cookies_are_send_in_plain(): void
{
$this->container->config(new CookieConfig(
plaintextCookies: ['Cookie_name'],
));

$controller = new class {
#[Get('/test_whitelisted_unencrypted_cookies_are_send_in_plain')]
public function __invoke(): Ok
{
return new Ok()->addCookie(
new Cookie(
key: 'Cookie_name',
value: 'value',
),
);
}
};

$reflection = new ReflectionClass($controller);
$method = $reflection->getMethod('__invoke');

$this->http
->registerRoute(new MethodReflector($method))
->get('/test_whitelisted_unencrypted_cookies_are_send_in_plain')
->assertOk()
->assertHeaderMatches('set-cookie', 'Cookie_name=value; Path=/; Secure; SameSite=Lax');
}

private function returnCookieValueController(): MethodReflector
{
$controller = new class() {
#[Get('/get_cookie_value')]
public function __invoke(Request $request): Ok
{
return new Ok(
$request->getCookie('Cookie_name')->value ?? '',
);
}
};

$reflection = new ReflectionClass($controller);
$method = $reflection->getMethod('__invoke');

return new MethodReflector($method);
}
}
Loading