diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md index c2297b0ae0..696c6328c6 100644 --- a/docs/1-essentials/01-routing.md +++ b/docs/1-essentials/01-routing.md @@ -875,6 +875,26 @@ final readonly class ErrorResponseProcessor implements ResponseProcessor } ``` +## Cookie management + +### Configuration + +By default, Tempest encrypts all cookies it sets, and discards any incoming cookie it cannot decrypt. + +This behaviour can be configured by creating a `cookie.config.php` file [anywhere](../1-essentials/06-configuration.md#configuration-files). + +```php app/cookie.config.php +use Tempest\Http\Cookie\CookieConfig; + +return new CookieConfig( + plaintextCookies: ['darkmode'], +); +``` + +**`discardUnencryptedCookies`** — When `true` (default), any incoming cookie that Tempest cannot decrypt will be discarded and the browser instructed to delete it. Set to `false` to silently ignore unencrypted cookies instead, leaving them intact in the browser. Note that either way, unencrypted cookies will not be accessible in the request object unless whitelisted via `plaintextCookies`. + +**`plaintextCookies`** — A list of cookie names that Tempest will not attempt to encrypt or decrypt. Whitelisted cookies are preserved in the browser, accessible in the request object, and sent to the browser in plaintext. Useful for cookies set by third-party services such as reverse proxies or CDNs, or cookies that must be readable using JavaScript (e.g. UI preferences like dark mode). + ## Session management Sessions in Tempest are managed by the {b`Tempest\Http\Session\Session`} class. It can be injected anywhere needed. As soon as the {b`Tempest\Http\Session\Session`} is injected, it is started behind the scenes. diff --git a/packages/http/src/Cookie/CookieConfig.php b/packages/http/src/Cookie/CookieConfig.php new file mode 100644 index 0000000000..d6055bd37b --- /dev/null +++ b/packages/http/src/Cookie/CookieConfig.php @@ -0,0 +1,18 @@ + $uploads, 'cookies' => Arr\filter(Arr\map( array: $_COOKIE, - map: function (string $value, string $key) { + map: function (string $rawValue, string $key) { try { return new Cookie( key: $key, - value: $this->encrypter->decrypt($value), + value: in_array($key, $this->cookieConfig->plaintextCookies, strict: true) + ? $rawValue + : $this->encrypter->decrypt($rawValue), ); } catch (Throwable) { - $this->cookies->remove($key); + if ($this->cookieConfig->discardUnencryptedCookies) { + $this->cookies->remove($key); + } return null; } diff --git a/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php b/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php index b368c3a26d..45982e0a73 100644 --- a/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php +++ b/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php @@ -20,6 +20,7 @@ use Tempest\Cryptography\Signing\SigningAlgorithm; use Tempest\Cryptography\Signing\SigningConfig; use Tempest\Cryptography\Timelock; +use Tempest\Http\Cookie\CookieConfig; use Tempest\Http\Cookie\CookieManager; use Tempest\Http\Mappers\PsrRequestToGenericRequestMapper; use Tempest\Http\Method; @@ -40,6 +41,7 @@ protected function setUp(): void new AppConfig(baseUri: 'https://test.com'), new GenericClock(), ), + new CookieConfig(), ); $reflection = new ReflectionClass($this->mapper); diff --git a/packages/router/src/SetCookieHeadersMiddleware.php b/packages/router/src/SetCookieHeadersMiddleware.php index f9b9779d3c..2ca9e652ad 100644 --- a/packages/router/src/SetCookieHeadersMiddleware.php +++ b/packages/router/src/SetCookieHeadersMiddleware.php @@ -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; @@ -19,6 +20,7 @@ public function __construct( private Encrypter $encrypter, private CookieManager $cookies, + private CookieConfig $cookieConfig, ) {} public function __invoke(Request $request, HttpMiddlewareCallable $next): Response @@ -26,9 +28,11 @@ public function __invoke(Request $request, HttpMiddlewareCallable $next): Respon $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, strict: true) => $cookie->value, + default => $this->encrypter->encrypt($cookie->value)->serialize(), + }; $response->addHeader('set-cookie', (string) $cookie->withValue($cookieValue)); } diff --git a/tests/Integration/Http/CookieHandlingTest.php b/tests/Integration/Http/CookieHandlingTest.php new file mode 100644 index 0000000000..bfc8622c12 --- /dev/null +++ b/tests/Integration/Http/CookieHandlingTest.php @@ -0,0 +1,198 @@ +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); + } +} diff --git a/tests/Integration/Route/PsrRequestToGenericRequestMapperTest.php b/tests/Integration/Route/PsrRequestToGenericRequestMapperTest.php index c2b9e06d1e..6bca650fdb 100644 --- a/tests/Integration/Route/PsrRequestToGenericRequestMapperTest.php +++ b/tests/Integration/Route/PsrRequestToGenericRequestMapperTest.php @@ -9,6 +9,7 @@ use Laminas\Diactoros\UploadedFile; use Laminas\Diactoros\Uri; use Tempest\Cryptography\Encryption\Encrypter; +use Tempest\Http\Cookie\CookieConfig; use Tempest\Http\Cookie\CookieManager; use Tempest\Http\GenericRequest; use Tempest\Http\Mappers\PsrRequestToGenericRequestMapper; @@ -30,7 +31,7 @@ final class PsrRequestToGenericRequestMapperTest extends FrameworkIntegrationTes } private PsrRequestToGenericRequestMapper $mapper { - get => new PsrRequestToGenericRequestMapper($this->encrypter, $this->cookies); + get => new PsrRequestToGenericRequestMapper($this->encrypter, $this->cookies, new CookieConfig()); } public function test_generic_request_is_used_when_interface_is_passed(): void